@proyecto-viviana/solidaria-components 0.2.5 → 0.2.9

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 (194) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ActionBar.d.ts +71 -0
  3. package/dist/ActionBar.d.ts.map +1 -0
  4. package/dist/ActionGroup.d.ts +74 -0
  5. package/dist/ActionGroup.d.ts.map +1 -0
  6. package/dist/Alert.d.ts +70 -0
  7. package/dist/Alert.d.ts.map +1 -0
  8. package/dist/Breadcrumbs.d.ts +10 -2
  9. package/dist/Breadcrumbs.d.ts.map +1 -1
  10. package/dist/Button.d.ts +4 -0
  11. package/dist/Button.d.ts.map +1 -1
  12. package/dist/Calendar.d.ts +13 -0
  13. package/dist/Calendar.d.ts.map +1 -1
  14. package/dist/Checkbox.d.ts +2 -2
  15. package/dist/Checkbox.d.ts.map +1 -1
  16. package/dist/Collection.d.ts +125 -0
  17. package/dist/Collection.d.ts.map +1 -0
  18. package/dist/Color.d.ts +114 -2
  19. package/dist/Color.d.ts.map +1 -1
  20. package/dist/ColorEditor.d.ts +42 -0
  21. package/dist/ColorEditor.d.ts.map +1 -0
  22. package/dist/ComboBox.d.ts +64 -0
  23. package/dist/ComboBox.d.ts.map +1 -1
  24. package/dist/ContextualHelpTrigger.d.ts +40 -0
  25. package/dist/ContextualHelpTrigger.d.ts.map +1 -0
  26. package/dist/DateField.d.ts +27 -2
  27. package/dist/DateField.d.ts.map +1 -1
  28. package/dist/DatePicker.d.ts +67 -2
  29. package/dist/DatePicker.d.ts.map +1 -1
  30. package/dist/Dialog.d.ts.map +1 -1
  31. package/dist/Disclosure.d.ts +2 -0
  32. package/dist/Disclosure.d.ts.map +1 -1
  33. package/dist/DragAndDrop.d.ts +80 -0
  34. package/dist/DragAndDrop.d.ts.map +1 -0
  35. package/dist/DragPreview.d.ts +14 -0
  36. package/dist/DragPreview.d.ts.map +1 -0
  37. package/dist/DropZone.d.ts +27 -0
  38. package/dist/DropZone.d.ts.map +1 -0
  39. package/dist/FieldError.d.ts +23 -0
  40. package/dist/FieldError.d.ts.map +1 -0
  41. package/dist/FileTrigger.d.ts +26 -0
  42. package/dist/FileTrigger.d.ts.map +1 -0
  43. package/dist/Focusable.d.ts +27 -0
  44. package/dist/Focusable.d.ts.map +1 -0
  45. package/dist/Form.d.ts +27 -0
  46. package/dist/Form.d.ts.map +1 -0
  47. package/dist/GridList.d.ts +40 -1
  48. package/dist/GridList.d.ts.map +1 -1
  49. package/dist/Icon.d.ts +57 -0
  50. package/dist/Icon.d.ts.map +1 -0
  51. package/dist/Keyboard.d.ts +13 -0
  52. package/dist/Keyboard.d.ts.map +1 -0
  53. package/dist/Link.d.ts.map +1 -1
  54. package/dist/ListBox.d.ts +43 -1
  55. package/dist/ListBox.d.ts.map +1 -1
  56. package/dist/ListDropTargetDelegate.d.ts +38 -0
  57. package/dist/ListDropTargetDelegate.d.ts.map +1 -0
  58. package/dist/Menu.d.ts +20 -2
  59. package/dist/Menu.d.ts.map +1 -1
  60. package/dist/Meter.d.ts +2 -2
  61. package/dist/Meter.d.ts.map +1 -1
  62. package/dist/Modal.d.ts +2 -0
  63. package/dist/Modal.d.ts.map +1 -1
  64. package/dist/NumberField.d.ts +2 -0
  65. package/dist/NumberField.d.ts.map +1 -1
  66. package/dist/Popover.d.ts +4 -2
  67. package/dist/Popover.d.ts.map +1 -1
  68. package/dist/Pressable.d.ts +27 -0
  69. package/dist/Pressable.d.ts.map +1 -0
  70. package/dist/ProgressBar.d.ts +2 -2
  71. package/dist/ProgressBar.d.ts.map +1 -1
  72. package/dist/RadioGroup.d.ts.map +1 -1
  73. package/dist/RangeCalendar.d.ts +5 -0
  74. package/dist/RangeCalendar.d.ts.map +1 -1
  75. package/dist/RouterProvider.d.ts +75 -0
  76. package/dist/RouterProvider.d.ts.map +1 -0
  77. package/dist/SearchField.d.ts +2 -3
  78. package/dist/SearchField.d.ts.map +1 -1
  79. package/dist/Select.d.ts +11 -0
  80. package/dist/Select.d.ts.map +1 -1
  81. package/dist/SelectionIndicator.d.ts +30 -0
  82. package/dist/SelectionIndicator.d.ts.map +1 -0
  83. package/dist/SharedElementTransition.d.ts +39 -0
  84. package/dist/SharedElementTransition.d.ts.map +1 -0
  85. package/dist/Slider.d.ts +6 -3
  86. package/dist/Slider.d.ts.map +1 -1
  87. package/dist/Table.d.ts +39 -0
  88. package/dist/Table.d.ts.map +1 -1
  89. package/dist/Tabs.d.ts +4 -3
  90. package/dist/Tabs.d.ts.map +1 -1
  91. package/dist/TagGroup.d.ts +12 -2
  92. package/dist/TagGroup.d.ts.map +1 -1
  93. package/dist/Text.d.ts +10 -0
  94. package/dist/Text.d.ts.map +1 -0
  95. package/dist/TextField.d.ts +4 -0
  96. package/dist/TextField.d.ts.map +1 -1
  97. package/dist/TimeField.d.ts +26 -1
  98. package/dist/TimeField.d.ts.map +1 -1
  99. package/dist/Toast.d.ts.map +1 -1
  100. package/dist/ToggleButton.d.ts +30 -0
  101. package/dist/ToggleButton.d.ts.map +1 -0
  102. package/dist/ToggleButtonGroup.d.ts +33 -0
  103. package/dist/ToggleButtonGroup.d.ts.map +1 -0
  104. package/dist/Toolbar.d.ts.map +1 -1
  105. package/dist/Tooltip.d.ts +9 -0
  106. package/dist/Tooltip.d.ts.map +1 -1
  107. package/dist/Tree.d.ts +44 -2
  108. package/dist/Tree.d.ts.map +1 -1
  109. package/dist/Virtualizer.d.ts +61 -0
  110. package/dist/Virtualizer.d.ts.map +1 -0
  111. package/dist/VirtualizerLayouts.d.ts +82 -0
  112. package/dist/VirtualizerLayouts.d.ts.map +1 -0
  113. package/dist/VisuallyHidden.d.ts +3 -1
  114. package/dist/VisuallyHidden.d.ts.map +1 -1
  115. package/dist/contexts.d.ts +1 -0
  116. package/dist/contexts.d.ts.map +1 -1
  117. package/dist/index.d.ts +57 -25
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +13961 -5946
  120. package/dist/index.js.map +1 -7
  121. package/dist/index.ssr.js +9612 -2401
  122. package/dist/index.ssr.js.map +1 -7
  123. package/dist/useDragAndDrop.d.ts +93 -0
  124. package/dist/useDragAndDrop.d.ts.map +1 -0
  125. package/dist/utils.d.ts +7 -1
  126. package/dist/utils.d.ts.map +1 -1
  127. package/dist/virtualizer/Layout.d.ts +79 -0
  128. package/dist/virtualizer/Layout.d.ts.map +1 -0
  129. package/package.json +8 -6
  130. package/src/ActionBar.tsx +248 -0
  131. package/src/ActionGroup.tsx +285 -0
  132. package/src/Alert.tsx +177 -0
  133. package/src/Autocomplete.tsx +1 -1
  134. package/src/Breadcrumbs.tsx +103 -17
  135. package/src/Button.tsx +65 -21
  136. package/src/Calendar.tsx +179 -53
  137. package/src/Checkbox.tsx +1 -2
  138. package/src/Collection.tsx +341 -0
  139. package/src/Color.tsx +652 -34
  140. package/src/ColorEditor.tsx +231 -0
  141. package/src/ComboBox.tsx +315 -81
  142. package/src/ContextualHelpTrigger.tsx +183 -0
  143. package/src/DateField.tsx +93 -19
  144. package/src/DatePicker.tsx +495 -25
  145. package/src/Dialog.tsx +40 -9
  146. package/src/Disclosure.tsx +33 -27
  147. package/src/DragAndDrop.tsx +334 -0
  148. package/src/DragPreview.tsx +45 -0
  149. package/src/DropZone.tsx +213 -0
  150. package/src/FieldError.tsx +67 -0
  151. package/src/FileTrigger.tsx +83 -0
  152. package/src/Focusable.tsx +106 -0
  153. package/src/Form.tsx +85 -0
  154. package/src/GridList.tsx +379 -41
  155. package/src/Icon.tsx +154 -0
  156. package/src/Keyboard.tsx +26 -0
  157. package/src/Link.tsx +14 -1
  158. package/src/ListBox.tsx +484 -33
  159. package/src/ListDropTargetDelegate.ts +282 -0
  160. package/src/Menu.tsx +388 -35
  161. package/src/Meter.tsx +7 -3
  162. package/src/Modal.tsx +32 -4
  163. package/src/NumberField.tsx +163 -43
  164. package/src/Popover.tsx +136 -180
  165. package/src/Pressable.tsx +108 -0
  166. package/src/ProgressBar.tsx +7 -3
  167. package/src/RadioGroup.tsx +35 -25
  168. package/src/RangeCalendar.tsx +100 -68
  169. package/src/RouterProvider.tsx +240 -0
  170. package/src/SearchField.tsx +142 -34
  171. package/src/Select.tsx +221 -73
  172. package/src/SelectionIndicator.tsx +105 -0
  173. package/src/SharedElementTransition.tsx +258 -0
  174. package/src/Slider.tsx +16 -6
  175. package/src/Table.tsx +417 -57
  176. package/src/Tabs.tsx +68 -35
  177. package/src/TagGroup.tsx +121 -36
  178. package/src/Text.tsx +18 -0
  179. package/src/TextField.tsx +25 -8
  180. package/src/TimeField.tsx +101 -151
  181. package/src/Toast.tsx +108 -14
  182. package/src/ToggleButton.tsx +159 -0
  183. package/src/ToggleButtonGroup.tsx +136 -0
  184. package/src/Toolbar.tsx +14 -8
  185. package/src/Tooltip.tsx +108 -19
  186. package/src/Tree.tsx +1143 -87
  187. package/src/Virtualizer.tsx +702 -0
  188. package/src/VirtualizerLayouts.ts +265 -0
  189. package/src/VisuallyHidden.tsx +15 -21
  190. package/src/contexts.ts +1 -0
  191. package/src/index.ts +1057 -620
  192. package/src/useDragAndDrop.ts +351 -0
  193. package/src/utils.tsx +37 -3
  194. package/src/virtualizer/Layout.ts +200 -0
package/src/Tree.tsx CHANGED
@@ -10,7 +10,9 @@
10
10
 
11
11
  import {
12
12
  type JSX,
13
+ onCleanup,
13
14
  createContext,
15
+ createEffect,
14
16
  createMemo,
15
17
  createSignal,
16
18
  splitProps,
@@ -24,6 +26,7 @@ import {
24
26
  createTreeSelectionCheckbox,
25
27
  createFocusRing,
26
28
  createHover,
29
+ mergeProps,
27
30
  type AriaTreeProps,
28
31
  } from '@proyecto-viviana/solidaria';
29
32
  import {
@@ -34,6 +37,8 @@ import {
34
37
  type TreeNode,
35
38
  type TreeItemData,
36
39
  type Key,
40
+ type DropTarget,
41
+ type ItemDropTarget,
37
42
  } from '@proyecto-viviana/solid-stately';
38
43
  import {
39
44
  type RenderChildren,
@@ -43,6 +48,27 @@ import {
43
48
  useRenderProps,
44
49
  filterDOMProps,
45
50
  } from './utils';
51
+ import { SharedElementTransition } from './SharedElementTransition';
52
+ import { type DragAndDropHooks } from './useDragAndDrop';
53
+ import {
54
+ getNormalizedDropTargetKey,
55
+ mergePersistedKeysIntoVirtualRange,
56
+ useDndPersistedKeys,
57
+ useRenderDropIndicator,
58
+ } from './DragAndDrop';
59
+ import {
60
+ CollectionRendererContext,
61
+ flattenCollectionEntries,
62
+ isCollectionSection,
63
+ Section,
64
+ Header,
65
+ type CollectionEntry,
66
+ type CollectionRendererContextValue,
67
+ type SectionProps,
68
+ type HeaderProps,
69
+ useCollectionRenderer,
70
+ } from './Collection';
71
+ import { useVirtualizerContext } from './Virtualizer';
46
72
 
47
73
  // ============================================
48
74
  // TYPES
@@ -61,9 +87,13 @@ export interface TreeRenderProps {
61
87
 
62
88
  export interface TreeProps<T extends object> extends Omit<AriaTreeProps, 'children'>, SlotProps {
63
89
  /** The hierarchical items to render in the tree. */
64
- items: TreeItemData<T>[];
90
+ items: CollectionEntry<TreeItemData<T>>[];
65
91
  /** The selection mode. */
66
92
  selectionMode?: 'none' | 'single' | 'multiple';
93
+ /** The selection behavior (toggle vs replace). */
94
+ selectionBehavior?: 'toggle' | 'replace';
95
+ /** Whether disabled items can still receive focus. */
96
+ disabledBehavior?: 'selection' | 'all';
67
97
  /** Keys of disabled items. */
68
98
  disabledKeys?: Iterable<Key>;
69
99
  /** Currently selected keys (controlled). */
@@ -86,6 +116,14 @@ export interface TreeProps<T extends object> extends Omit<AriaTreeProps, 'childr
86
116
  style?: StyleOrFunction<TreeRenderProps>;
87
117
  /** A function to render when the tree is empty. */
88
118
  renderEmptyState?: () => JSX.Element;
119
+ /** Whether there are more items to load. */
120
+ hasMore?: boolean;
121
+ /** Whether additional items are currently loading. */
122
+ isLoading?: boolean;
123
+ /** Called when the load more sentinel becomes visible. */
124
+ onLoadMore?: () => void | Promise<void>;
125
+ /** Drag and drop hooks from `useDragAndDrop`. */
126
+ dragAndDropHooks?: DragAndDropHooks<T>;
89
127
  }
90
128
 
91
129
  export interface TreeRenderItemState {
@@ -118,7 +156,7 @@ export interface TreeItemRenderProps {
118
156
  level: number;
119
157
  }
120
158
 
121
- export interface TreeItemProps<T extends object> extends SlotProps {
159
+ export interface TreeItemProps<T extends object> extends SlotProps, Omit<JSX.HTMLAttributes<HTMLDivElement>, 'class' | 'style' | 'children' | 'id'> {
122
160
  /** The unique key for the item. */
123
161
  id: Key;
124
162
  /** The item value. */
@@ -144,6 +182,17 @@ export interface TreeExpandButtonProps {
144
182
  children?: JSX.Element | ((props: { isExpanded: boolean }) => JSX.Element);
145
183
  }
146
184
 
185
+ export interface TreeLoadMoreItemProps extends SlotProps {
186
+ onLoadMore: () => void | Promise<void>;
187
+ isLoading?: boolean;
188
+ children?: JSX.Element;
189
+ class?: ClassNameOrFunction<{ isLoading: boolean }>;
190
+ style?: StyleOrFunction<{ isLoading: boolean }>;
191
+ }
192
+
193
+ export interface TreeSectionProps extends SectionProps {}
194
+ export interface TreeHeaderProps extends HeaderProps {}
195
+
147
196
  // ============================================
148
197
  // CONTEXT
149
198
  // ============================================
@@ -153,6 +202,500 @@ interface TreeContextValue<T extends object> {
153
202
  collection: TreeCollection<T>;
154
203
  isDisabled: boolean;
155
204
  renderItem: (item: TreeItemData<T>, state: TreeRenderItemState) => JSX.Element;
205
+ dragAndDropHooks?: DragAndDropHooks<T>;
206
+ dragState?: unknown;
207
+ dropState?: unknown;
208
+ }
209
+
210
+ interface TreeDropTargetDelegate {
211
+ getDropTargetFromPoint: (
212
+ x: number,
213
+ y: number,
214
+ isValidDropTarget: (target: DropTarget) => boolean
215
+ ) => DropTarget | null;
216
+ getKeyboardNavigationTarget?: (
217
+ target: DropTarget | null,
218
+ direction: 'next' | 'previous',
219
+ isValidDropTarget: (target: DropTarget) => boolean
220
+ ) => DropTarget | null;
221
+ getKeyboardPageNavigationTarget?: (
222
+ target: DropTarget | null,
223
+ direction: 'next' | 'previous',
224
+ isValidDropTarget: (target: DropTarget) => boolean
225
+ ) => DropTarget | null;
226
+ }
227
+
228
+ interface PointerTrackingState {
229
+ lastY: number;
230
+ lastX: number;
231
+ yDirection: 'up' | 'down' | null;
232
+ xDirection: 'left' | 'right' | null;
233
+ boundaryContext: {
234
+ parentKey: Key;
235
+ lastSwitchY: number;
236
+ lastSwitchX: number;
237
+ preferredTargetIndex?: number;
238
+ } | null;
239
+ }
240
+
241
+ const X_SWITCH_THRESHOLD = 10;
242
+ const Y_SWITCH_THRESHOLD = 5;
243
+ const EXPANSION_KEYS = {
244
+ expand: { ltr: 'ArrowRight', rtl: 'ArrowLeft' },
245
+ collapse: { ltr: 'ArrowLeft', rtl: 'ArrowRight' },
246
+ } as const;
247
+
248
+ function resolveTreeDirection(element: HTMLElement | null): 'ltr' | 'rtl' {
249
+ if (element) {
250
+ const dir = element.closest('[dir]')?.getAttribute('dir');
251
+ if (dir === 'rtl') return 'rtl';
252
+ if (dir === 'ltr') return 'ltr';
253
+ if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
254
+ const computedDirection = window.getComputedStyle(element).direction;
255
+ if (computedDirection === 'rtl') return 'rtl';
256
+ if (computedDirection === 'ltr') return 'ltr';
257
+ }
258
+ }
259
+ if (typeof document !== 'undefined') {
260
+ return document.dir === 'rtl' ? 'rtl' : 'ltr';
261
+ }
262
+ return 'ltr';
263
+ }
264
+
265
+ function createTreeDropTargetDelegate<T extends object>(
266
+ delegate: TreeDropTargetDelegate,
267
+ state: TreeState<T, TreeCollection<T>>,
268
+ direction: 'ltr' | 'rtl',
269
+ baseKeyboardNav?: (
270
+ target: DropTarget | null,
271
+ direction: 'next' | 'previous',
272
+ isValidDropTarget: (target: DropTarget) => boolean
273
+ ) => DropTarget | null
274
+ ): TreeDropTargetDelegate {
275
+ const pointerTracking: PointerTrackingState = {
276
+ lastY: 0,
277
+ lastX: 0,
278
+ yDirection: null,
279
+ xDirection: null,
280
+ boundaryContext: null,
281
+ };
282
+
283
+ const getPotentialTargets = (
284
+ originalTarget: ItemDropTarget,
285
+ isValidDropTarget: (target: DropTarget) => boolean
286
+ ): ItemDropTarget[] => {
287
+ if (originalTarget.dropPosition === 'on') return [originalTarget];
288
+
289
+ const collection = state.collection;
290
+ const getNodeNextKey = (node: TreeNode<T> | null | undefined): Key | null => {
291
+ if (!node) return null;
292
+ const declaredNextKey = (node as TreeNode<T> & { nextKey?: Key | null }).nextKey;
293
+ return declaredNextKey ?? null;
294
+ };
295
+ const target: ItemDropTarget = { ...originalTarget };
296
+ let currentItem = collection.getItem(target.key);
297
+ while (currentItem && currentItem.type !== 'item') {
298
+ const nextKey = getNodeNextKey(currentItem);
299
+ if (nextKey == null) break;
300
+ target.key = nextKey;
301
+ currentItem = collection.getItem(nextKey);
302
+ }
303
+
304
+ const potentialTargets: ItemDropTarget[] = [target];
305
+
306
+ if (
307
+ currentItem &&
308
+ currentItem.hasChildNodes &&
309
+ state.expandedKeys.has(currentItem.key) &&
310
+ target.dropPosition === 'after'
311
+ ) {
312
+ let firstChildItemNode: TreeNode<T> | null = null;
313
+ for (const child of collection.getChildren(currentItem.key)) {
314
+ if (child.type === 'item') {
315
+ firstChildItemNode = child;
316
+ break;
317
+ }
318
+ }
319
+
320
+ if (firstChildItemNode) {
321
+ const beforeFirstChildTarget: ItemDropTarget = {
322
+ type: 'item',
323
+ key: firstChildItemNode.key,
324
+ dropPosition: 'before',
325
+ };
326
+
327
+ if (isValidDropTarget(beforeFirstChildTarget)) {
328
+ return [beforeFirstChildTarget];
329
+ }
330
+ return [];
331
+ }
332
+ }
333
+
334
+ if (getNodeNextKey(currentItem) != null) {
335
+ return [originalTarget];
336
+ }
337
+
338
+ let parentKey = currentItem?.parentKey ?? null;
339
+ const ancestorTargets: ItemDropTarget[] = [];
340
+ while (parentKey != null) {
341
+ const parentItem = collection.getItem(parentKey);
342
+ const nextKey = getNodeNextKey(parentItem);
343
+ const nextItem = nextKey != null ? collection.getItem(nextKey) : null;
344
+ const isLastChildAtLevel = !nextItem || nextItem.parentKey !== parentKey;
345
+
346
+ if (isLastChildAtLevel) {
347
+ const afterParentTarget: ItemDropTarget = {
348
+ type: 'item',
349
+ key: parentKey,
350
+ dropPosition: 'after',
351
+ };
352
+
353
+ if (isValidDropTarget(afterParentTarget)) {
354
+ ancestorTargets.push(afterParentTarget);
355
+ }
356
+ if (nextItem) break;
357
+ }
358
+
359
+ parentKey = parentItem?.parentKey ?? null;
360
+ }
361
+
362
+ if (ancestorTargets.length > 0) {
363
+ potentialTargets.push(...ancestorTargets);
364
+ }
365
+
366
+ if (potentialTargets.length === 1) {
367
+ const nextKey = collection.getKeyAfter(target.key);
368
+ const nextNode = nextKey != null ? collection.getItem(nextKey) : null;
369
+ if (
370
+ nextKey != null &&
371
+ nextNode &&
372
+ currentItem &&
373
+ nextNode.level != null &&
374
+ currentItem.level != null &&
375
+ nextNode.level > currentItem.level
376
+ ) {
377
+ const beforeTarget: ItemDropTarget = {
378
+ type: 'item',
379
+ key: nextKey,
380
+ dropPosition: 'before',
381
+ };
382
+ if (isValidDropTarget(beforeTarget)) return [beforeTarget];
383
+ }
384
+ }
385
+
386
+ return potentialTargets.filter((candidate) => isValidDropTarget(candidate));
387
+ };
388
+
389
+ const selectTarget = (
390
+ potentialTargets: ItemDropTarget[],
391
+ originalTarget: ItemDropTarget,
392
+ x: number,
393
+ y: number,
394
+ currentYMovement: 'up' | 'down' | null,
395
+ currentXMovement: 'left' | 'right' | null
396
+ ): ItemDropTarget => {
397
+ if (potentialTargets.length < 2) return potentialTargets[0];
398
+
399
+ const currentItem = state.collection.getItem(originalTarget.key);
400
+ const parentKey = currentItem?.parentKey;
401
+ if (parentKey == null) return potentialTargets[0];
402
+
403
+ if (!pointerTracking.boundaryContext || pointerTracking.boundaryContext.parentKey !== parentKey) {
404
+ const initialTargetIndex = pointerTracking.yDirection === 'up' ? potentialTargets.length - 1 : 0;
405
+ pointerTracking.boundaryContext = {
406
+ parentKey,
407
+ preferredTargetIndex: initialTargetIndex,
408
+ lastSwitchY: y,
409
+ lastSwitchX: x,
410
+ };
411
+ }
412
+
413
+ const boundaryContext = pointerTracking.boundaryContext;
414
+ const distanceFromLastXSwitch = Math.abs(x - boundaryContext.lastSwitchX);
415
+ const distanceFromLastYSwitch = Math.abs(y - boundaryContext.lastSwitchY);
416
+
417
+ if (distanceFromLastYSwitch > Y_SWITCH_THRESHOLD && currentYMovement) {
418
+ const currentIndex = boundaryContext.preferredTargetIndex ?? 0;
419
+ if (currentYMovement === 'down' && currentIndex === 0) {
420
+ boundaryContext.preferredTargetIndex = potentialTargets.length - 1;
421
+ } else if (currentYMovement === 'up' && currentIndex === potentialTargets.length - 1) {
422
+ boundaryContext.preferredTargetIndex = 0;
423
+ }
424
+ pointerTracking.xDirection = null;
425
+ }
426
+
427
+ if (distanceFromLastXSwitch > X_SWITCH_THRESHOLD && currentXMovement) {
428
+ const currentTargetIndex = boundaryContext.preferredTargetIndex ?? 0;
429
+
430
+ if (currentXMovement === 'left') {
431
+ if (direction === 'ltr') {
432
+ if (currentTargetIndex < potentialTargets.length - 1) {
433
+ boundaryContext.preferredTargetIndex = currentTargetIndex + 1;
434
+ boundaryContext.lastSwitchX = x;
435
+ }
436
+ } else if (currentTargetIndex > 0) {
437
+ boundaryContext.preferredTargetIndex = currentTargetIndex - 1;
438
+ boundaryContext.lastSwitchX = x;
439
+ }
440
+ } else if (currentXMovement === 'right') {
441
+ if (direction === 'ltr') {
442
+ if (currentTargetIndex > 0) {
443
+ boundaryContext.preferredTargetIndex = currentTargetIndex - 1;
444
+ boundaryContext.lastSwitchX = x;
445
+ }
446
+ } else if (currentTargetIndex < potentialTargets.length - 1) {
447
+ boundaryContext.preferredTargetIndex = currentTargetIndex + 1;
448
+ boundaryContext.lastSwitchX = x;
449
+ }
450
+ }
451
+
452
+ pointerTracking.yDirection = null;
453
+ }
454
+
455
+ const targetIndex = Math.max(
456
+ 0,
457
+ Math.min(boundaryContext.preferredTargetIndex ?? 0, potentialTargets.length - 1)
458
+ );
459
+ return potentialTargets[targetIndex];
460
+ };
461
+
462
+ // --- Tree-aware keyboard DnD navigation (RAC parity) ---
463
+ const getKeyboardNavigationTarget = (
464
+ target: DropTarget | null,
465
+ dir: 'next' | 'previous',
466
+ isValidDropTarget: (target: DropTarget) => boolean
467
+ ): DropTarget | null => {
468
+ const collection = state.collection;
469
+
470
+ // If the target key is not a visible row (e.g. collapsed/hidden child node),
471
+ // fall back to the base (non-override) index-based navigation to avoid infinite recursion.
472
+ // The collection keyMap contains ALL nodes (even collapsed), so check visible rows instead.
473
+ if (target && target.type === 'item') {
474
+ const node = collection.getItem(target.key);
475
+ const isVisibleRow = node != null && (node as TreeNode<T> & { rowIndex?: number }).rowIndex != null;
476
+ if (!isVisibleRow) {
477
+ return baseKeyboardNav?.(target, dir, isValidDropTarget) ?? null;
478
+ }
479
+ }
480
+
481
+ // Helpers
482
+ const tryValid = (t: DropTarget): DropTarget | null =>
483
+ isValidDropTarget(t) ? t : null;
484
+
485
+ const getNodeNextKey = (node: TreeNode<T> | null | undefined): Key | null => {
486
+ if (!node) return null;
487
+ return (node as TreeNode<T> & { nextKey?: Key | null }).nextKey ?? null;
488
+ };
489
+
490
+ const isExpanded = (key: Key): boolean => {
491
+ const node = collection.getItem(key);
492
+ if (!node || !node.hasChildNodes) return false;
493
+ return state.expandedKeys.has(key);
494
+ };
495
+
496
+ const getFirstChildItemKey = (key: Key): Key | null => {
497
+ for (const child of collection.getChildren(key)) {
498
+ if (child.type === 'item') return child.key;
499
+ }
500
+ return null;
501
+ };
502
+
503
+ const getLastChildItemKey = (key: Key): Key | null => {
504
+ let lastKey: Key | null = null;
505
+ for (const child of collection.getChildren(key)) {
506
+ if (child.type === 'item') lastKey = child.key;
507
+ }
508
+ return lastKey;
509
+ };
510
+
511
+ // Find the deepest last expanded descendant (for "previous" from 'after')
512
+ const getDeepestLastChild = (key: Key): Key => {
513
+ let current = key;
514
+ while (isExpanded(current)) {
515
+ const lastChild = getLastChildItemKey(current);
516
+ if (lastChild == null) break;
517
+ current = lastChild;
518
+ }
519
+ return current;
520
+ };
521
+
522
+ if (dir === 'next') {
523
+ // From null → root
524
+ if (!target) {
525
+ return tryValid({ type: 'root' });
526
+ }
527
+ // From root → first item 'before'
528
+ if (target.type === 'root') {
529
+ const firstKey = collection.getFirstKey();
530
+ if (firstKey != null) {
531
+ return tryValid({ type: 'item', key: firstKey, dropPosition: 'before' });
532
+ }
533
+ return null;
534
+ }
535
+ if (target.type === 'item') {
536
+ switch (target.dropPosition) {
537
+ case 'before':
538
+ return tryValid({ type: 'item', key: target.key, dropPosition: 'on' })
539
+ ?? tryValid({ type: 'item', key: target.key, dropPosition: 'after' });
540
+ case 'on': {
541
+ // If item is expanded and has children, go to first child 'before'
542
+ if (isExpanded(target.key)) {
543
+ const firstChild = getFirstChildItemKey(target.key);
544
+ if (firstChild != null) {
545
+ return tryValid({ type: 'item', key: firstChild, dropPosition: 'before' })
546
+ ?? tryValid({ type: 'item', key: firstChild, dropPosition: 'on' });
547
+ }
548
+ }
549
+ // Otherwise, next item in collection or 'after'
550
+ const nextKey = collection.getKeyAfter(target.key);
551
+ const targetNode = collection.getItem(target.key);
552
+ const nextNode = nextKey != null ? collection.getItem(nextKey) : null;
553
+ if (targetNode && nextNode && nextNode.level != null && targetNode.level != null && nextNode.level >= targetNode.level) {
554
+ return tryValid({ type: 'item', key: nextNode.key, dropPosition: 'before' })
555
+ ?? tryValid({ type: 'item', key: target.key, dropPosition: 'after' });
556
+ }
557
+ return tryValid({ type: 'item', key: target.key, dropPosition: 'after' });
558
+ }
559
+ case 'after': {
560
+ // If item is expanded (and we're at 'after'), first child
561
+ if (isExpanded(target.key)) {
562
+ const firstChild = getFirstChildItemKey(target.key);
563
+ if (firstChild != null) {
564
+ return tryValid({ type: 'item', key: firstChild, dropPosition: 'before' })
565
+ ?? tryValid({ type: 'item', key: firstChild, dropPosition: 'on' });
566
+ }
567
+ }
568
+ // Check if this is the last sibling at its level
569
+ const targetNode = collection.getItem(target.key);
570
+ const nextSiblingKey = getNodeNextKey(targetNode);
571
+ if (nextSiblingKey != null) {
572
+ const nextSibling = collection.getItem(nextSiblingKey);
573
+ if (nextSibling?.type === 'item') {
574
+ return tryValid({ type: 'item', key: nextSibling.key, dropPosition: 'before' })
575
+ ?? tryValid({ type: 'item', key: nextSibling.key, dropPosition: 'on' });
576
+ }
577
+ }
578
+ // Traverse up to parent when at last sibling
579
+ if (targetNode?.parentKey != null) {
580
+ const parentNode = collection.getItem(targetNode.parentKey);
581
+ const parentNextKey = getNodeNextKey(parentNode);
582
+ const parentNextNode = parentNextKey != null ? collection.getItem(parentNextKey) : null;
583
+ if (parentNextNode?.type === 'item') {
584
+ return tryValid({ type: 'item', key: parentNextNode.key, dropPosition: 'before' });
585
+ }
586
+ if (parentNode?.type === 'item') {
587
+ return tryValid({ type: 'item', key: parentNode.key, dropPosition: 'after' });
588
+ }
589
+ }
590
+ // Reached end — try next item in flat collection
591
+ const nextKey = collection.getKeyAfter(target.key);
592
+ if (nextKey != null) {
593
+ return tryValid({ type: 'item', key: nextKey, dropPosition: 'before' })
594
+ ?? tryValid({ type: 'item', key: nextKey, dropPosition: 'on' });
595
+ }
596
+ // Wrap to root
597
+ return tryValid({ type: 'root' });
598
+ }
599
+ }
600
+ }
601
+ return null;
602
+ }
603
+
604
+ // dir === 'previous'
605
+ // From null or root → last root-level item 'after'
606
+ if (!target || target.type === 'root') {
607
+ const lastKey = collection.getLastKey();
608
+ if (lastKey != null) {
609
+ // Find root-level ancestor of last key
610
+ let rootKey = lastKey;
611
+ let node = collection.getItem(lastKey);
612
+ while (node?.parentKey != null) {
613
+ rootKey = node.parentKey;
614
+ node = collection.getItem(rootKey);
615
+ }
616
+ return tryValid({ type: 'item', key: rootKey, dropPosition: 'after' });
617
+ }
618
+ return null;
619
+ }
620
+
621
+ if (target.type === 'item') {
622
+ switch (target.dropPosition) {
623
+ case 'after': {
624
+ // If expanded with children, go to deepest last child 'after'
625
+ const deepest = getDeepestLastChild(target.key);
626
+ if (deepest !== target.key) {
627
+ return tryValid({ type: 'item', key: deepest, dropPosition: 'after' })
628
+ ?? tryValid({ type: 'item', key: target.key, dropPosition: 'on' });
629
+ }
630
+ return tryValid({ type: 'item', key: target.key, dropPosition: 'on' });
631
+ }
632
+ case 'on':
633
+ return tryValid({ type: 'item', key: target.key, dropPosition: 'before' });
634
+ case 'before': {
635
+ // Move to the previous sibling's deepest last child 'after'
636
+ const prevKey = collection.getKeyBefore(target.key);
637
+ if (prevKey != null) {
638
+ const deepest = getDeepestLastChild(prevKey);
639
+ return tryValid({ type: 'item', key: deepest, dropPosition: 'after' })
640
+ ?? tryValid({ type: 'item', key: prevKey, dropPosition: 'on' });
641
+ }
642
+ // No previous — go to root
643
+ return tryValid({ type: 'root' });
644
+ }
645
+ }
646
+ }
647
+
648
+ return null;
649
+ };
650
+
651
+ return {
652
+ getDropTargetFromPoint(x, y, isValidDropTarget) {
653
+ const baseTarget = delegate.getDropTargetFromPoint(x, y, isValidDropTarget);
654
+ if (!baseTarget || baseTarget.type === 'root') return baseTarget;
655
+
656
+ const deltaY = y - pointerTracking.lastY;
657
+ const deltaX = x - pointerTracking.lastX;
658
+ let currentYMovement: 'up' | 'down' | null = pointerTracking.yDirection;
659
+ let currentXMovement: 'left' | 'right' | null = pointerTracking.xDirection;
660
+
661
+ if (Math.abs(deltaY) > Y_SWITCH_THRESHOLD) {
662
+ currentYMovement = deltaY > 0 ? 'down' : 'up';
663
+ pointerTracking.yDirection = currentYMovement;
664
+ pointerTracking.lastY = y;
665
+ }
666
+
667
+ if (Math.abs(deltaX) > X_SWITCH_THRESHOLD) {
668
+ currentXMovement = deltaX > 0 ? 'right' : 'left';
669
+ pointerTracking.xDirection = currentXMovement;
670
+ pointerTracking.lastX = x;
671
+ }
672
+
673
+ let target: ItemDropTarget = baseTarget;
674
+ if (target.dropPosition === 'before') {
675
+ const keyBefore = state.collection.getKeyBefore(target.key);
676
+ if (keyBefore != null) {
677
+ const normalized: ItemDropTarget = {
678
+ type: 'item',
679
+ key: keyBefore,
680
+ dropPosition: 'after',
681
+ };
682
+ if (isValidDropTarget(normalized)) target = normalized;
683
+ }
684
+ }
685
+
686
+ const potentialTargets = getPotentialTargets(target, isValidDropTarget);
687
+ if (potentialTargets.length === 0) return { type: 'root' };
688
+
689
+ if (potentialTargets.length > 1) {
690
+ return selectTarget(potentialTargets, target, x, y, currentYMovement, currentXMovement);
691
+ }
692
+
693
+ pointerTracking.boundaryContext = null;
694
+ return potentialTargets[0];
695
+ },
696
+ getKeyboardNavigationTarget,
697
+ getKeyboardPageNavigationTarget: delegate.getKeyboardPageNavigationTarget?.bind(delegate),
698
+ };
156
699
  }
157
700
 
158
701
  interface TreeItemContextValue<T extends object> {
@@ -177,11 +720,13 @@ export const TreeItemContext = createContext<TreeItemContextValue<object> | null
177
720
  export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
178
721
  const [local, stateProps, ariaProps] = splitProps(
179
722
  props,
180
- ['class', 'style', 'slot', 'renderEmptyState'],
723
+ ['class', 'style', 'slot', 'renderEmptyState', 'hasMore', 'isLoading', 'onLoadMore', 'dragAndDropHooks'],
181
724
  [
182
725
  'items',
183
726
  'disabledKeys',
727
+ 'disabledBehavior',
184
728
  'selectionMode',
729
+ 'selectionBehavior',
185
730
  'selectedKeys',
186
731
  'defaultSelectedKeys',
187
732
  'onSelectionChange',
@@ -193,13 +738,17 @@ export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
193
738
 
194
739
  // Create ref signal
195
740
  const [ref, setRef] = createSignal<HTMLDivElement | null>(null);
741
+ const flatItems = createMemo<TreeItemData<T>[]>(() => flattenCollectionEntries(stateProps.items));
742
+ const hasSections = createMemo(() => stateProps.items.some((entry) => isCollectionSection(entry)));
196
743
 
197
744
  // Create tree state
198
745
  const state = createTreeState<T, TreeCollection<T>>(() => ({
199
746
  collectionFactory: (expandedKeys) =>
200
- createTreeCollection(stateProps.items, expandedKeys) as TreeCollection<T>,
747
+ createTreeCollection(flatItems(), expandedKeys) as TreeCollection<T>,
201
748
  disabledKeys: stateProps.disabledKeys,
749
+ disabledBehavior: stateProps.disabledBehavior,
202
750
  selectionMode: stateProps.selectionMode,
751
+ selectionBehavior: stateProps.selectionBehavior,
203
752
  selectedKeys: stateProps.selectedKeys,
204
753
  defaultSelectedKeys: stateProps.defaultSelectedKeys,
205
754
  onSelectionChange: stateProps.onSelectionChange,
@@ -208,6 +757,22 @@ export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
208
757
  onExpandedChange: stateProps.onExpandedChange,
209
758
  }));
210
759
 
760
+ const [lastExpandedKeys, setLastExpandedKeys] = createSignal<Set<Key>>(new Set());
761
+ const [lastItemsLength, setLastItemsLength] = createSignal(flatItems().length);
762
+ const [collectionVersion, setCollectionVersion] = createSignal(0);
763
+ createEffect(() => {
764
+ const expanded = state.expandedKeys;
765
+ const items = flatItems();
766
+ if (!areSetsEqual(lastExpandedKeys(), expanded) || lastItemsLength() !== items.length) {
767
+ setLastExpandedKeys(new Set(expanded));
768
+ setLastItemsLength(items.length);
769
+ setCollectionVersion((v) => v + 1);
770
+ }
771
+ });
772
+
773
+ // Resolve writing direction for keyboard expand/collapse parity
774
+ const treeDirection = createMemo(() => ariaProps.direction ?? resolveTreeDirection(ref()));
775
+
211
776
  // Create tree aria props
212
777
  const { treeProps } = createTree<T, TreeCollection<T>>(
213
778
  () => ({
@@ -218,6 +783,7 @@ export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
218
783
  isVirtualized: ariaProps.isVirtualized,
219
784
  onAction: ariaProps.onAction,
220
785
  isDisabled: ariaProps.isDisabled,
786
+ direction: treeDirection(),
221
787
  }),
222
788
  () => state,
223
789
  ref
@@ -231,7 +797,7 @@ export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
231
797
  isFocused: state.isFocused || isFocused(),
232
798
  isFocusVisible: isFocusVisible(),
233
799
  isDisabled: ariaProps.isDisabled ?? false,
234
- isEmpty: stateProps.items.length === 0,
800
+ isEmpty: flatItems().length === 0,
235
801
  }));
236
802
 
237
803
  // Resolve render props
@@ -260,61 +826,444 @@ export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
260
826
  return rest;
261
827
  };
262
828
 
263
- const isEmpty = () => stateProps.items.length === 0;
829
+ const isEmpty = () => flatItems().length === 0;
830
+
831
+ // Render visible rows (flat list based on expansion state)
832
+ const visibleRows = createMemo(() => {
833
+ collectionVersion();
834
+ return state.collection.rows;
835
+ });
836
+ const virtualizer = useVirtualizerContext();
837
+ const parentCollectionRenderer = useCollectionRenderer<TreeItemData<T>>();
838
+ const getDropTargetByIndex = (index: number, position: 'before' | 'after' | 'on'): DropTarget | null => {
839
+ const node = visibleRows()[index];
840
+ if (!node) return null;
841
+ return { type: 'item', key: node.key, dropPosition: position };
842
+ };
843
+ const hasDroppableDnd = createMemo(() => {
844
+ const hooks = local.dragAndDropHooks;
845
+ return Boolean(
846
+ hooks?.useDroppableCollectionState &&
847
+ hooks.useDroppableCollection &&
848
+ (hooks.dropTargetDelegate || parentCollectionRenderer?.dropTargetDelegate || hooks.ListDropTargetDelegate)
849
+ );
850
+ });
851
+ const hasDraggableDnd = createMemo(() => {
852
+ const hooks = local.dragAndDropHooks;
853
+ return Boolean(hooks?.useDraggableCollectionState && hooks.useDraggableCollection);
854
+ });
855
+ const dragState = createMemo(() => {
856
+ if (!hasDraggableDnd()) return undefined;
857
+ return local.dragAndDropHooks?.useDraggableCollectionState?.({
858
+ items: visibleRows().map((node) => node.value as T),
859
+ });
860
+ });
861
+ const dropState = createMemo(() => {
862
+ if (!hasDroppableDnd()) return undefined;
863
+ return local.dragAndDropHooks?.useDroppableCollectionState?.({});
864
+ });
865
+ createEffect(() => {
866
+ const activeDropState = dropState();
867
+ if (!activeDropState) return;
868
+ const originalGetDropOperation = activeDropState.getDropOperation.bind(activeDropState);
869
+
870
+ activeDropState.getDropOperation = (target, types, allowedOperations) => {
871
+ const currentDraggingKeys = dragState()?.draggingKeys ?? new Set<string | number>();
872
+ if (target.type === 'item' && currentDraggingKeys.size > 0) {
873
+ if (currentDraggingKeys.has(target.key) && target.dropPosition === 'on') {
874
+ return 'cancel';
875
+ }
876
+
877
+ let currentKey: Key | null = target.key;
878
+ while (currentKey != null) {
879
+ const item = state.collection.getItem(currentKey);
880
+ const parentKey = item?.parentKey;
881
+ if (parentKey != null && currentDraggingKeys.has(parentKey)) {
882
+ return 'cancel';
883
+ }
884
+ currentKey = parentKey ?? null;
885
+ }
886
+ }
887
+
888
+ return originalGetDropOperation(target, types, allowedOperations);
889
+ };
264
890
 
891
+ onCleanup(() => {
892
+ activeDropState.getDropOperation = originalGetDropOperation;
893
+ });
894
+ });
895
+ createEffect(() => {
896
+ if (!hasDraggableDnd()) return;
897
+ const hooks = local.dragAndDropHooks;
898
+ const activeDragState = dragState();
899
+ if (!hooks?.useDraggableCollection || !activeDragState) return;
900
+ hooks.useDraggableCollection({}, activeDragState, () => ref());
901
+ });
265
902
  const contextValue = createMemo<TreeContextValue<T>>(() => ({
266
903
  state,
267
904
  collection: state.collection,
268
905
  isDisabled: ariaProps.isDisabled ?? false,
269
906
  renderItem: props.children,
907
+ dragAndDropHooks: local.dragAndDropHooks,
908
+ dragState: dragState(),
909
+ dropState: dropState(),
270
910
  }));
911
+ const droppableCollection = createMemo(() => {
912
+ if (!hasDroppableDnd()) return undefined;
913
+ const hooks = local.dragAndDropHooks;
914
+ const activeDropState = dropState();
915
+ if (!hooks?.useDroppableCollection || !activeDropState) return undefined;
916
+ const direction = resolveTreeDirection(ref());
917
+ const baseDropTargetDelegate = hooks.dropTargetDelegate
918
+ ?? parentCollectionRenderer?.dropTargetDelegate
919
+ ?? (hooks.ListDropTargetDelegate
920
+ ? new hooks.ListDropTargetDelegate(
921
+ () => state.collection,
922
+ () => ref(),
923
+ { layout: 'stack', orientation: 'vertical', direction }
924
+ )
925
+ : undefined);
926
+ if (!baseDropTargetDelegate) return undefined;
927
+ const dropTargetDelegate = createTreeDropTargetDelegate(
928
+ baseDropTargetDelegate as TreeDropTargetDelegate,
929
+ state,
930
+ direction,
931
+ virtualizer?.getBaseKeyboardNavigationTarget
932
+ );
933
+ return hooks.useDroppableCollection(
934
+ {
935
+ dropTargetDelegate,
936
+ keyboardDelegate: {
937
+ getFirstKey: () => state.collection.getFirstKey(),
938
+ getLastKey: () => state.collection.getLastKey(),
939
+ getKeyBelow: (key) => state.collection.getKeyAfter(key),
940
+ getKeyAbove: (key) => state.collection.getKeyBefore(key),
941
+ getKeyPageBelow: (key) => state.collection.getKeyAfter(key),
942
+ getKeyPageAbove: (key) => state.collection.getKeyBefore(key),
943
+ },
944
+ onDropActivate: (event) => {
945
+ if (event.target.type !== 'item') return;
946
+ const key = event.target.key;
947
+ const item = state.collection.getItem(key);
948
+ const isExpanded = state.isExpanded(key);
949
+ if (item?.hasChildNodes && (!isExpanded || hooks.isVirtualDragging?.())) {
950
+ state.toggleKey(key);
951
+ }
952
+ },
953
+ onKeyDown: (event) => {
954
+ const target = activeDropState.target;
955
+ if (!target || target.type !== 'item' || target.dropPosition !== 'on') return;
956
+ const item = state.collection.getItem(target.key);
957
+ if (!item?.hasChildNodes) return;
958
+ const expandKey = EXPANSION_KEYS.expand[direction];
959
+ const collapseKey = EXPANSION_KEYS.collapse[direction];
960
+ if (event.key === expandKey && !state.isExpanded(target.key)) {
961
+ state.toggleKey(target.key);
962
+ } else if (event.key === collapseKey && state.isExpanded(target.key)) {
963
+ state.toggleKey(target.key);
964
+ }
965
+ },
966
+ },
967
+ activeDropState,
968
+ () => ref()
969
+ );
970
+ });
971
+ const isRootDropTarget = createMemo(() => {
972
+ return Boolean(dropState()?.target?.type === 'root');
973
+ });
974
+ const dndRenderDropIndicator = createMemo(() => useRenderDropIndicator(local.dragAndDropHooks, dropState()));
975
+ const dndDropIndicator = (index: number, position: 'before' | 'after' | 'on') => {
976
+ const target = getDropTargetByIndex(index, position);
977
+ if (!target || target.type !== 'item') return undefined;
978
+ return dndRenderDropIndicator()?.(target);
979
+ };
980
+ const persistedKeys = useDndPersistedKeys(
981
+ { focusedKey: () => state.focusedKey },
982
+ local.dragAndDropHooks,
983
+ dropState(),
984
+ state.collection
985
+ );
986
+ const virtualRange = createMemo(() => {
987
+ if (!virtualizer || !parentCollectionRenderer?.isVirtualized) return null;
988
+ const rows = visibleRows();
989
+ const baseRange = virtualizer.getVisibleRange(rows.length);
990
+ const persistedIndexes = Array.from(persistedKeys())
991
+ .map((key) => rows.findIndex((node) => node.key === key))
992
+ .filter((index) => index >= 0);
993
+ const dropTarget = dropState()?.target;
994
+ const normalizedDropKey = getNormalizedDropTargetKey(dropTarget, state.collection);
995
+ const focusedKey = state.focusedKey;
996
+ const focusedIndex = focusedKey != null ? rows.findIndex((node) => node.key === focusedKey) : -1;
997
+ const forceIncludeIndexes = [
998
+ dropTarget?.type === 'item' ? rows.findIndex((node) => node.key === dropTarget.key) : -1,
999
+ normalizedDropKey != null ? rows.findIndex((node) => node.key === normalizedDropKey) : -1,
1000
+ dropTarget?.type === 'item' ? -1 : focusedIndex,
1001
+ ].filter((index) => index >= 0);
1002
+ return mergePersistedKeysIntoVirtualRange(baseRange, persistedIndexes, rows.length, virtualizer, 80, {
1003
+ forceIncludeIndexes,
1004
+ forceIncludeMaxSpan: 320,
1005
+ });
1006
+ });
1007
+ const virtualizedVisibleRows = createMemo(() => {
1008
+ const range = virtualRange();
1009
+ if (!range) return visibleRows();
1010
+ return visibleRows().slice(range.start, range.end);
1011
+ });
1012
+ createEffect(() => {
1013
+ if (!virtualizer || !parentCollectionRenderer?.isVirtualized) return;
1014
+ virtualizer.setDropTargetItemCountResolver(() => visibleRows().length);
1015
+ virtualizer.setDropTargetIndexResolver((key) => {
1016
+ const rows = visibleRows();
1017
+ const index = rows.findIndex((node) => node.key === key);
1018
+ return index >= 0 ? index : null;
1019
+ });
1020
+ virtualizer.setDropTargetResolver((target) => {
1021
+ const node = visibleRows()[target.index];
1022
+ if (!node) return target;
1023
+ return {
1024
+ ...target,
1025
+ key: typeof node.key === 'string' || typeof node.key === 'number' ? node.key : undefined,
1026
+ parentKey:
1027
+ typeof node.parentKey === 'string' || typeof node.parentKey === 'number'
1028
+ ? node.parentKey
1029
+ : node.parentKey == null
1030
+ ? null
1031
+ : undefined,
1032
+ level: typeof node.level === 'number' ? node.level : undefined,
1033
+ };
1034
+ });
1035
+ onCleanup(() => {
1036
+ virtualizer.setDropTargetIndexResolver(undefined);
1037
+ virtualizer.setDropTargetItemCountResolver(undefined);
1038
+ virtualizer.setDropTargetResolver(undefined);
1039
+ });
1040
+ });
1041
+ const rowIndexByKey = createMemo(() => {
1042
+ const map = new Map<Key, number>();
1043
+ const rows = visibleRows();
1044
+ for (let i = 0; i < rows.length; i += 1) {
1045
+ map.set(rows[i].key, i);
1046
+ }
1047
+ return map;
1048
+ });
1049
+ const getAfterIndicatorIndexes = (
1050
+ absoluteIndex: number,
1051
+ renderRange?: { start: number; end: number } | null
1052
+ ): number[] => {
1053
+ const rows = visibleRows();
1054
+ const current = rows[absoluteIndex];
1055
+ if (!current) return [];
1056
+ const next = rows[absoluteIndex + 1];
1057
+ // "after" is equivalent to next sibling's "before" when next row is at same or deeper level.
1058
+ if (next && next.level >= current.level) {
1059
+ return [];
1060
+ }
271
1061
 
272
- // Render visible rows (flat list based on expansion state)
273
- const visibleRows = createMemo(() => state.collection.rows);
1062
+ const result: number[] = [];
1063
+ let cursorIndex: number | null = absoluteIndex;
1064
+
1065
+ // Emit after indicators for current and ancestor boundary levels, matching RAC branch exit semantics.
1066
+ while (cursorIndex != null) {
1067
+ const cursor: TreeNode<T> | undefined = rows[cursorIndex];
1068
+ if (!cursor) break;
1069
+ const shouldRender =
1070
+ !next || (cursor.parentKey !== next.parentKey && next.level < cursor.level);
1071
+ if (!shouldRender) break;
1072
+ result.push(cursorIndex);
1073
+ if (cursor.parentKey == null) break;
1074
+ cursorIndex = rowIndexByKey().get(cursor.parentKey) ?? null;
1075
+ }
1076
+ if (!renderRange) return result;
1077
+ return result.filter((index) => index >= renderRange.start && index < renderRange.end);
1078
+ };
1079
+ // Install tree-aware keyboard navigation override into the Virtualizer (if present).
1080
+ // This replaces the generic index-based navigation with collection-level semantics
1081
+ // (tree branch traversal, level-aware wrapping — RAC parity item #36).
1082
+ createEffect(() => {
1083
+ if (!virtualizer) return;
1084
+ const direction = resolveTreeDirection(ref());
1085
+ const parentDelegate: TreeDropTargetDelegate = {
1086
+ getDropTargetFromPoint: parentCollectionRenderer?.dropTargetDelegate?.getDropTargetFromPoint
1087
+ ?? ((_x, _y, _v) => null),
1088
+ getKeyboardNavigationTarget: parentCollectionRenderer?.dropTargetDelegate?.getKeyboardNavigationTarget,
1089
+ getKeyboardPageNavigationTarget: parentCollectionRenderer?.dropTargetDelegate?.getKeyboardPageNavigationTarget,
1090
+ };
1091
+ const treeDelegate = createTreeDropTargetDelegate(
1092
+ parentDelegate, state, direction,
1093
+ virtualizer.getBaseKeyboardNavigationTarget
1094
+ );
1095
+ virtualizer.setKeyboardNavigationOverride(
1096
+ treeDelegate.getKeyboardNavigationTarget
1097
+ ? (target, dir, isValid) => treeDelegate.getKeyboardNavigationTarget!(target, dir, isValid)
1098
+ : undefined
1099
+ );
1100
+ onCleanup(() => {
1101
+ virtualizer.setKeyboardNavigationOverride(undefined);
1102
+ });
1103
+ });
1104
+ const collectionRenderer = createMemo<CollectionRendererContextValue<unknown>>(() => ({
1105
+ ...parentCollectionRenderer,
1106
+ renderItem: (item) => item as JSX.Element,
1107
+ renderDropIndicator: (index: number, position: 'before' | 'after' | 'on') =>
1108
+ dndDropIndicator(index, position) ?? parentCollectionRenderer?.renderDropIndicator?.(index, position),
1109
+ }));
1110
+ const rootKeyByNodeKey = createMemo(() => {
1111
+ const rootMap = new Map<Key, Key>();
1112
+ for (const row of visibleRows()) {
1113
+ let rootKey: Key = row.key;
1114
+ let parentKey = row.parentKey;
1115
+ while (parentKey != null) {
1116
+ rootKey = parentKey;
1117
+ parentKey = state.collection.getParentKey(parentKey);
1118
+ }
1119
+ rootMap.set(row.key, rootKey);
1120
+ }
1121
+ return rootMap;
1122
+ });
1123
+ const renderRange = createMemo(() => {
1124
+ const range = virtualRange();
1125
+ if (!range) return null;
1126
+ return { start: range.start, end: range.end };
1127
+ });
1128
+ const renderableRows = createMemo(() => {
1129
+ const offset = renderRange()?.start ?? 0;
1130
+ return virtualizedVisibleRows().map((node, index) => ({
1131
+ node,
1132
+ globalIndex: offset + index,
1133
+ }));
1134
+ });
1135
+ const sectionedRenderableRows = createMemo(() => {
1136
+ if (!hasSections()) return null;
1137
+ const rootMap = rootKeyByNodeKey();
1138
+ const rows = renderableRows();
1139
+ return stateProps.items.map((entry) => {
1140
+ if (!isCollectionSection(entry)) {
1141
+ const matching = rows.filter((row) => rootMap.get(row.node.key) === entry.key);
1142
+ return {
1143
+ type: 'single' as const,
1144
+ item: entry,
1145
+ rows: matching,
1146
+ };
1147
+ }
1148
+ const sectionRootKeys = new Set(entry.items.map((item) => item.key));
1149
+ const sectionRows = rows.filter((row) => {
1150
+ const rootKey = rootMap.get(row.node.key);
1151
+ return rootKey != null && sectionRootKeys.has(rootKey);
1152
+ });
1153
+ return {
1154
+ type: 'section' as const,
1155
+ section: entry,
1156
+ rows: sectionRows,
1157
+ };
1158
+ });
1159
+ });
1160
+ const renderTreeRow = (node: TreeNode<T>, itemIndex: number) => {
1161
+ const beforeIndicator = () => collectionRenderer().renderDropIndicator?.(itemIndex, 'before');
1162
+ const onIndicator = () => collectionRenderer().renderDropIndicator?.(itemIndex, 'on');
1163
+ const afterIndicatorIndexes = () => getAfterIndicatorIndexes(itemIndex, renderRange());
1164
+ // Find the original item data to pass to render function
1165
+ const itemData: TreeItemData<T> = {
1166
+ key: node.key,
1167
+ value: node.value as T,
1168
+ textValue: node.textValue,
1169
+ children: node.hasChildNodes
1170
+ ? node.childNodes.map((child) => ({
1171
+ key: child.key,
1172
+ value: child.value as T,
1173
+ textValue: child.textValue,
1174
+ }))
1175
+ : undefined,
1176
+ };
1177
+ const itemState: TreeRenderItemState = {
1178
+ isExpanded: node.isExpanded ?? false,
1179
+ isExpandable: node.isExpandable ?? false,
1180
+ level: node.level,
1181
+ };
1182
+ return (
1183
+ <>
1184
+ {beforeIndicator()}
1185
+ {onIndicator()}
1186
+ {props.children(itemData, itemState)}
1187
+ <For each={afterIndicatorIndexes()}>
1188
+ {(afterIndex) => collectionRenderer().renderDropIndicator?.(afterIndex, 'after')}
1189
+ </For>
1190
+ </>
1191
+ );
1192
+ };
274
1193
 
275
1194
  return (
276
1195
  <TreeContext.Provider value={contextValue() as unknown as TreeContextValue<object>}>
277
1196
  <TreeStateContext.Provider value={state as unknown as TreeState<object, TreeCollection<object>>}>
278
- <div
279
- ref={setRef}
280
- {...domProps()}
281
- {...cleanTreeProps()}
282
- {...cleanFocusProps()}
283
- class={renderProps.class()}
284
- style={renderProps.style()}
285
- data-focused={state.isFocused || undefined}
286
- data-focus-visible={isFocusVisible() || undefined}
287
- data-disabled={ariaProps.isDisabled || undefined}
288
- data-empty={isEmpty() || undefined}
289
- >
290
- {isEmpty() && local.renderEmptyState ? (
291
- local.renderEmptyState()
292
- ) : (
293
- <For each={visibleRows()}>
294
- {(node) => {
295
- // Find the original item data to pass to render function
296
- const itemData: TreeItemData<T> = {
297
- key: node.key,
298
- value: node.value as T,
299
- textValue: node.textValue,
300
- children: node.hasChildNodes
301
- ? node.childNodes.map((child) => ({
302
- key: child.key,
303
- value: child.value as T,
304
- textValue: child.textValue,
305
- }))
306
- : undefined,
307
- };
308
- const itemState: TreeRenderItemState = {
309
- isExpanded: node.isExpanded ?? false,
310
- isExpandable: node.isExpandable ?? false,
311
- level: node.level,
312
- };
313
- return props.children(itemData, itemState);
314
- }}
315
- </For>
316
- )}
317
- </div>
1197
+ <CollectionRendererContext.Provider value={collectionRenderer()}>
1198
+ <div
1199
+ ref={setRef}
1200
+ {...mergeProps(
1201
+ domProps(),
1202
+ cleanTreeProps(),
1203
+ cleanFocusProps(),
1204
+ (droppableCollection()?.collectionProps as Record<string, unknown> | undefined) ?? {}
1205
+ )}
1206
+ class={renderProps.class()}
1207
+ style={renderProps.style()}
1208
+ data-focused={state.isFocused || undefined}
1209
+ data-focus-visible={isFocusVisible() || undefined}
1210
+ data-disabled={ariaProps.isDisabled || undefined}
1211
+ data-empty={isEmpty() || undefined}
1212
+ data-drop-target={isRootDropTarget() || undefined}
1213
+ data-selection-mode={stateProps.selectionMode !== 'none' ? stateProps.selectionMode : undefined}
1214
+ data-allows-dragging={hasDraggableDnd() || undefined}
1215
+ >
1216
+ <SharedElementTransition>
1217
+ {isEmpty() && local.renderEmptyState ? (
1218
+ local.renderEmptyState()
1219
+ ) : (
1220
+ <>
1221
+ {virtualRange()?.offsetTop
1222
+ ? <div role="presentation" aria-hidden="true" style={{ height: `${virtualRange()!.offsetTop}px` }} data-virtualizer-spacer="top" />
1223
+ : null}
1224
+ <Show
1225
+ when={hasSections()}
1226
+ fallback={(
1227
+ <For each={renderableRows()}>
1228
+ {(row) => renderTreeRow(row.node, row.globalIndex)}
1229
+ </For>
1230
+ )}
1231
+ >
1232
+ <For each={sectionedRenderableRows() ?? []}>
1233
+ {(entry) => (
1234
+ <Show when={entry.rows.length > 0}>
1235
+ <Show
1236
+ when={entry.type === 'section'}
1237
+ fallback={(
1238
+ <For each={entry.rows}>
1239
+ {(row) => renderTreeRow(row.node, row.globalIndex)}
1240
+ </For>
1241
+ )}
1242
+ >
1243
+ <TreeSection>
1244
+ {entry.type === 'section' && entry.section.title
1245
+ ? <TreeHeader>{entry.section.title}</TreeHeader>
1246
+ : null}
1247
+ <For each={entry.rows}>
1248
+ {(row) => renderTreeRow(row.node, row.globalIndex)}
1249
+ </For>
1250
+ </TreeSection>
1251
+ </Show>
1252
+ </Show>
1253
+ )}
1254
+ </For>
1255
+ </Show>
1256
+ {virtualRange()?.offsetBottom
1257
+ ? <div role="presentation" aria-hidden="true" style={{ height: `${virtualRange()!.offsetBottom}px` }} data-virtualizer-spacer="bottom" />
1258
+ : null}
1259
+ </>
1260
+ )}
1261
+ </SharedElementTransition>
1262
+ {local.hasMore && local.onLoadMore && (
1263
+ <TreeLoadMoreItem onLoadMore={local.onLoadMore} isLoading={local.isLoading} />
1264
+ )}
1265
+ </div>
1266
+ </CollectionRendererContext.Provider>
318
1267
  </TreeStateContext.Provider>
319
1268
  </TreeContext.Provider>
320
1269
  );
@@ -324,7 +1273,7 @@ export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
324
1273
  * An item in a tree.
325
1274
  */
326
1275
  export function TreeItem<T extends object>(props: TreeItemProps<T>): JSX.Element {
327
- const [local] = splitProps(props, [
1276
+ const [local, domProps] = splitProps(props, [
328
1277
  'class',
329
1278
  'style',
330
1279
  'slot',
@@ -332,6 +1281,7 @@ export function TreeItem<T extends object>(props: TreeItemProps<T>): JSX.Element
332
1281
  'item',
333
1282
  'textValue',
334
1283
  'onAction',
1284
+ 'children',
335
1285
  ]);
336
1286
 
337
1287
  // Get state from context
@@ -340,6 +1290,7 @@ export function TreeItem<T extends object>(props: TreeItemProps<T>): JSX.Element
340
1290
  throw new Error('TreeItem must be used within a Tree');
341
1291
  }
342
1292
  const state = context as TreeState<T, TreeCollection<T>>;
1293
+ const treeContext = useContext(TreeContext) as TreeContextValue<T> | null;
343
1294
 
344
1295
  // Create ref signal
345
1296
  const [ref, setRef] = createSignal<HTMLDivElement | null>(null);
@@ -366,29 +1317,26 @@ export function TreeItem<T extends object>(props: TreeItemProps<T>): JSX.Element
366
1317
  });
367
1318
 
368
1319
  // Create item aria props
369
- const {
370
- rowProps,
371
- gridCellProps,
372
- expandButtonProps: _expandButtonProps,
373
- isSelected,
374
- isDisabled,
375
- isPressed,
376
- isExpanded,
377
- isExpandable,
378
- level,
379
- } = createTreeItem<T, TreeCollection<T>>(
1320
+ const treeItemAria = createTreeItem<T, TreeCollection<T>>(
380
1321
  () => ({
381
1322
  node: itemNode(),
382
1323
  onAction: local.onAction,
1324
+ textValue: local.textValue,
383
1325
  }),
384
1326
  () => state,
385
1327
  ref
386
1328
  );
1329
+ const isSelected = () => treeItemAria.isSelected;
1330
+ const isDisabled = () => treeItemAria.isDisabled;
1331
+ const isPressed = () => treeItemAria.isPressed;
1332
+ const isExpanded = () => treeItemAria.isExpanded;
1333
+ const isExpandable = () => treeItemAria.isExpandable;
1334
+ const level = () => treeItemAria.level;
387
1335
 
388
1336
  // Create hover
389
1337
  const { isHovered, hoverProps } = createHover({
390
1338
  get isDisabled() {
391
- return isDisabled;
1339
+ return isDisabled();
392
1340
  },
393
1341
  });
394
1342
 
@@ -397,18 +1345,37 @@ export function TreeItem<T extends object>(props: TreeItemProps<T>): JSX.Element
397
1345
 
398
1346
  // Check if focused
399
1347
  const isFocused = createMemo(() => state.focusedKey === local.id);
1348
+ const draggableItem = createMemo(() => {
1349
+ if (!treeContext?.dragAndDropHooks?.useDraggableItem || !treeContext.dragState) return undefined;
1350
+ return treeContext.dragAndDropHooks.useDraggableItem(
1351
+ {
1352
+ key: local.id as string | number,
1353
+ },
1354
+ treeContext.dragState as Parameters<NonNullable<DragAndDropHooks<T>['useDraggableItem']>>[1]
1355
+ );
1356
+ });
1357
+ const droppableItem = createMemo(() => {
1358
+ if (!treeContext?.dragAndDropHooks?.useDroppableItem || !treeContext.dropState) return undefined;
1359
+ return treeContext.dragAndDropHooks.useDroppableItem(
1360
+ {
1361
+ key: local.id as string | number,
1362
+ },
1363
+ treeContext.dropState as Parameters<NonNullable<DragAndDropHooks<T>['useDroppableItem']>>[1],
1364
+ () => ref()
1365
+ );
1366
+ });
400
1367
 
401
1368
  // Render props values
402
1369
  const renderValues = createMemo<TreeItemRenderProps>(() => ({
403
- isSelected,
1370
+ isSelected: isSelected(),
404
1371
  isFocused: isFocused(),
405
1372
  isFocusVisible: isFocusVisible() && isFocused(),
406
- isPressed,
1373
+ isPressed: isPressed(),
407
1374
  isHovered: isHovered(),
408
- isDisabled,
409
- isExpanded,
410
- isExpandable,
411
- level,
1375
+ isDisabled: isDisabled(),
1376
+ isExpanded: isExpanded(),
1377
+ isExpandable: isExpandable(),
1378
+ level: level(),
412
1379
  }));
413
1380
 
414
1381
  // Resolve render props
@@ -424,7 +1391,7 @@ export function TreeItem<T extends object>(props: TreeItemProps<T>): JSX.Element
424
1391
 
425
1392
  // Remove ref from spread props
426
1393
  const cleanRowProps = () => {
427
- const { ref: _ref1, ...rest } = rowProps as Record<string, unknown>;
1394
+ const { ref: _ref1, ...rest } = treeItemAria.rowProps as Record<string, unknown>;
428
1395
  return rest;
429
1396
  };
430
1397
  const cleanHoverProps = () => {
@@ -439,31 +1406,40 @@ export function TreeItem<T extends object>(props: TreeItemProps<T>): JSX.Element
439
1406
  // Item context for nested components
440
1407
  const itemContextValue = createMemo<TreeItemContextValue<T>>(() => ({
441
1408
  node: itemNode(),
442
- isExpanded,
443
- isExpandable,
444
- level,
1409
+ isExpanded: isExpanded(),
1410
+ isExpandable: isExpandable(),
1411
+ level: level(),
445
1412
  }));
446
1413
 
447
1414
  return (
448
- <TreeItemContext.Provider value={itemContextValue() as TreeItemContextValue<object>}>
1415
+ <TreeItemContext.Provider value={itemContextValue() as unknown as TreeItemContextValue<object>}>
449
1416
  <div
450
1417
  ref={setRef}
451
- {...cleanRowProps()}
452
- {...cleanHoverProps()}
453
- {...cleanFocusProps()}
1418
+ {...domProps}
1419
+ {...mergeProps(
1420
+ cleanRowProps(),
1421
+ cleanHoverProps(),
1422
+ cleanFocusProps(),
1423
+ (draggableItem()?.dragProps as Record<string, unknown> | undefined) ?? {},
1424
+ (droppableItem()?.dropProps as Record<string, unknown> | undefined) ?? {}
1425
+ )}
454
1426
  class={renderProps.class()}
455
- style={renderProps.style()}
456
- data-selected={isSelected || undefined}
1427
+ style={{ '--tree-item-level': String(level()), ...((typeof renderProps.style() === 'object' ? renderProps.style() : {}) as Record<string, string>) }}
1428
+ data-selected={isSelected() || undefined}
457
1429
  data-focused={isFocused() || undefined}
458
1430
  data-focus-visible={(isFocusVisible() && isFocused()) || undefined}
459
- data-pressed={isPressed || undefined}
1431
+ data-pressed={isPressed() || undefined}
460
1432
  data-hovered={isHovered() || undefined}
461
- data-disabled={isDisabled || undefined}
462
- data-expanded={isExpanded || undefined}
463
- data-expandable={isExpandable || undefined}
464
- data-level={level}
1433
+ data-disabled={isDisabled() || undefined}
1434
+ data-expanded={isExpanded() || undefined}
1435
+ data-expandable={isExpandable() || undefined}
1436
+ data-has-child-items={isExpandable() || undefined}
1437
+ data-level={level()}
1438
+ data-selection-mode={treeContext?.state.selectionMode !== 'none' ? treeContext?.state.selectionMode : undefined}
1439
+ data-dragging={draggableItem()?.isDragging || undefined}
1440
+ data-drop-target={droppableItem()?.isDropTarget || undefined}
465
1441
  >
466
- <div {...gridCellProps} class="solidaria-Tree-item-content">
1442
+ <div {...treeItemAria.gridCellProps} class="solidaria-Tree-item-content">
467
1443
  {renderProps.renderChildren()}
468
1444
  </div>
469
1445
  </div>
@@ -490,7 +1466,7 @@ export function TreeExpandButton(props: TreeExpandButtonProps): JSX.Element {
490
1466
  const state = stateContext as TreeState<object, TreeCollection<object>>;
491
1467
 
492
1468
  // Create expand button props
493
- const { expandButtonProps } = createTreeItem(
1469
+ const treeItemAria = createTreeItem(
494
1470
  () => ({ node: itemContext.node }),
495
1471
  () => state,
496
1472
  () => null
@@ -498,7 +1474,7 @@ export function TreeExpandButton(props: TreeExpandButtonProps): JSX.Element {
498
1474
 
499
1475
  // Remove ref and add custom handling
500
1476
  const cleanExpandProps = () => {
501
- const { ref: _ref, ...rest } = expandButtonProps as Record<string, unknown>;
1477
+ const { ref: _ref, ...rest } = treeItemAria.expandButtonProps as Record<string, unknown>;
502
1478
  return rest;
503
1479
  };
504
1480
 
@@ -537,15 +1513,95 @@ export function TreeSelectionCheckbox(props: { itemKey: Key }): JSX.Element {
537
1513
 
538
1514
  const state = context as TreeState<object, TreeCollection<object>>;
539
1515
 
540
- const { checkboxProps } = createTreeSelectionCheckbox<object, TreeCollection<object>>(
1516
+ const treeSelectionCheckboxAria = createTreeSelectionCheckbox<object, TreeCollection<object>>(
541
1517
  () => ({ key: props.itemKey }),
542
1518
  () => state
543
1519
  );
544
1520
 
545
- return <input {...checkboxProps} class="solidaria-Tree-checkbox" />;
1521
+ return <input {...treeSelectionCheckboxAria.checkboxProps} class="solidaria-Tree-checkbox" />;
1522
+ }
1523
+
1524
+ export function TreeLoadMoreItem(props: TreeLoadMoreItemProps): JSX.Element {
1525
+ let ref: HTMLDivElement | undefined;
1526
+ const [isPending, setIsPending] = createSignal(false);
1527
+ const isLoading = () => !!props.isLoading || isPending();
1528
+
1529
+ const triggerLoadMore = async () => {
1530
+ if (isLoading()) return;
1531
+ setIsPending(true);
1532
+ try {
1533
+ await props.onLoadMore();
1534
+ } finally {
1535
+ setIsPending(false);
1536
+ }
1537
+ };
1538
+
1539
+ createEffect(() => {
1540
+ if (!ref || typeof IntersectionObserver !== 'function') return;
1541
+ const observer = new IntersectionObserver((entries) => {
1542
+ if (entries[0]?.isIntersecting) {
1543
+ void triggerLoadMore();
1544
+ }
1545
+ });
1546
+ observer.observe(ref);
1547
+ return () => observer.disconnect();
1548
+ });
1549
+
1550
+ const renderProps = useRenderProps(
1551
+ {
1552
+ children: props.children ?? (() => (isLoading() ? 'Loading more...' : 'Load more')),
1553
+ class: props.class,
1554
+ style: props.style,
1555
+ defaultClassName: 'solidaria-Tree-loadMore',
1556
+ },
1557
+ () => ({ isLoading: isLoading() })
1558
+ );
1559
+
1560
+ return (
1561
+ <div
1562
+ ref={ref}
1563
+ role="treeitem"
1564
+ tabIndex={0}
1565
+ aria-disabled={true}
1566
+ onFocus={() => {
1567
+ void triggerLoadMore();
1568
+ }}
1569
+ class={renderProps.class()}
1570
+ style={renderProps.style()}
1571
+ data-loading={isLoading() || undefined}
1572
+ >
1573
+ {renderProps.renderChildren()}
1574
+ </div>
1575
+ );
1576
+ }
1577
+
1578
+ export interface TreeItemContentProps<T extends object> extends TreeItemProps<T> {}
1579
+ export type TreeItemContentRenderProps = TreeItemRenderProps;
1580
+
1581
+ export function TreeItemContent<T extends object>(props: TreeItemContentProps<T>): JSX.Element {
1582
+ return <TreeItem {...props} />;
1583
+ }
1584
+
1585
+ export function TreeSection(props: TreeSectionProps): JSX.Element {
1586
+ return <Section {...props} />;
1587
+ }
1588
+
1589
+ export function TreeHeader(props: TreeHeaderProps): JSX.Element {
1590
+ return <Header {...props} />;
546
1591
  }
547
1592
 
548
1593
  // Attach static properties
549
1594
  Tree.Item = TreeItem;
550
1595
  Tree.ExpandButton = TreeExpandButton;
551
1596
  Tree.SelectionCheckbox = TreeSelectionCheckbox;
1597
+ Tree.LoadMoreItem = TreeLoadMoreItem;
1598
+ Tree.Section = TreeSection;
1599
+ Tree.Header = TreeHeader;
1600
+
1601
+ function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
1602
+ if (a.size !== b.size) return false;
1603
+ for (const entry of a) {
1604
+ if (!b.has(entry)) return false;
1605
+ }
1606
+ return true;
1607
+ }