@proyecto-viviana/solidaria-components 0.2.5 → 0.3.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 (225) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -272
  3. package/dist/ActionBar.d.ts +79 -0
  4. package/dist/ActionBar.d.ts.map +1 -0
  5. package/dist/ActionGroup.d.ts +74 -0
  6. package/dist/ActionGroup.d.ts.map +1 -0
  7. package/dist/Alert.d.ts +70 -0
  8. package/dist/Alert.d.ts.map +1 -0
  9. package/dist/Autocomplete.d.ts +5 -5
  10. package/dist/Autocomplete.d.ts.map +1 -1
  11. package/dist/Breadcrumbs.d.ts +27 -8
  12. package/dist/Breadcrumbs.d.ts.map +1 -1
  13. package/dist/Button.d.ts +28 -5
  14. package/dist/Button.d.ts.map +1 -1
  15. package/dist/Calendar.d.ts +51 -7
  16. package/dist/Calendar.d.ts.map +1 -1
  17. package/dist/Checkbox.d.ts +33 -8
  18. package/dist/Checkbox.d.ts.map +1 -1
  19. package/dist/Collection.d.ts +130 -0
  20. package/dist/Collection.d.ts.map +1 -0
  21. package/dist/Color.d.ts +210 -9
  22. package/dist/Color.d.ts.map +1 -1
  23. package/dist/ColorEditor.d.ts +42 -0
  24. package/dist/ColorEditor.d.ts.map +1 -0
  25. package/dist/ComboBox.d.ts +146 -16
  26. package/dist/ComboBox.d.ts.map +1 -1
  27. package/dist/ContextualHelpTrigger.d.ts +40 -0
  28. package/dist/ContextualHelpTrigger.d.ts.map +1 -0
  29. package/dist/DateField.d.ts +35 -8
  30. package/dist/DateField.d.ts.map +1 -1
  31. package/dist/DatePicker.d.ts +101 -5
  32. package/dist/DatePicker.d.ts.map +1 -1
  33. package/dist/DateRangePickerContext.d.ts +30 -0
  34. package/dist/DateRangePickerContext.d.ts.map +1 -0
  35. package/dist/Dialog.d.ts +5 -5
  36. package/dist/Dialog.d.ts.map +1 -1
  37. package/dist/Disclosure.d.ts +25 -5
  38. package/dist/Disclosure.d.ts.map +1 -1
  39. package/dist/DragAndDrop.d.ts +80 -0
  40. package/dist/DragAndDrop.d.ts.map +1 -0
  41. package/dist/DragPreview.d.ts +14 -0
  42. package/dist/DragPreview.d.ts.map +1 -0
  43. package/dist/DropZone.d.ts +27 -0
  44. package/dist/DropZone.d.ts.map +1 -0
  45. package/dist/FieldError.d.ts +27 -0
  46. package/dist/FieldError.d.ts.map +1 -0
  47. package/dist/FileTrigger.d.ts +26 -0
  48. package/dist/FileTrigger.d.ts.map +1 -0
  49. package/dist/Focusable.d.ts +27 -0
  50. package/dist/Focusable.d.ts.map +1 -0
  51. package/dist/Form.d.ts +41 -0
  52. package/dist/Form.d.ts.map +1 -0
  53. package/dist/GridList.d.ts +69 -10
  54. package/dist/GridList.d.ts.map +1 -1
  55. package/dist/HiddenDateInput.d.ts +26 -0
  56. package/dist/HiddenDateInput.d.ts.map +1 -0
  57. package/dist/HiddenTimeInput.d.ts +25 -0
  58. package/dist/HiddenTimeInput.d.ts.map +1 -0
  59. package/dist/Icon.d.ts +57 -0
  60. package/dist/Icon.d.ts.map +1 -0
  61. package/dist/Keyboard.d.ts +13 -0
  62. package/dist/Keyboard.d.ts.map +1 -0
  63. package/dist/Landmark.d.ts +3 -3
  64. package/dist/Landmark.d.ts.map +1 -1
  65. package/dist/Link.d.ts +10 -4
  66. package/dist/Link.d.ts.map +1 -1
  67. package/dist/ListBox.d.ts +73 -11
  68. package/dist/ListBox.d.ts.map +1 -1
  69. package/dist/ListDropTargetDelegate.d.ts +38 -0
  70. package/dist/ListDropTargetDelegate.d.ts.map +1 -0
  71. package/dist/Menu.d.ts +79 -10
  72. package/dist/Menu.d.ts.map +1 -1
  73. package/dist/Meter.d.ts +4 -4
  74. package/dist/Meter.d.ts.map +1 -1
  75. package/dist/Modal.d.ts +6 -4
  76. package/dist/Modal.d.ts.map +1 -1
  77. package/dist/NumberField.d.ts +10 -12
  78. package/dist/NumberField.d.ts.map +1 -1
  79. package/dist/Popover.d.ts +32 -7
  80. package/dist/Popover.d.ts.map +1 -1
  81. package/dist/Pressable.d.ts +27 -0
  82. package/dist/Pressable.d.ts.map +1 -0
  83. package/dist/ProgressBar.d.ts +6 -4
  84. package/dist/ProgressBar.d.ts.map +1 -1
  85. package/dist/RadioGroup.d.ts +43 -9
  86. package/dist/RadioGroup.d.ts.map +1 -1
  87. package/dist/RangeCalendar.d.ts +39 -7
  88. package/dist/RangeCalendar.d.ts.map +1 -1
  89. package/dist/RouterProvider.d.ts +75 -0
  90. package/dist/RouterProvider.d.ts.map +1 -0
  91. package/dist/SearchField.d.ts +23 -21
  92. package/dist/SearchField.d.ts.map +1 -1
  93. package/dist/Select.d.ts +48 -7
  94. package/dist/Select.d.ts.map +1 -1
  95. package/dist/SelectionIndicator.d.ts +30 -0
  96. package/dist/SelectionIndicator.d.ts.map +1 -0
  97. package/dist/Separator.d.ts +9 -3
  98. package/dist/Separator.d.ts.map +1 -1
  99. package/dist/SharedElementTransition.d.ts +41 -0
  100. package/dist/SharedElementTransition.d.ts.map +1 -0
  101. package/dist/Slider.d.ts +15 -8
  102. package/dist/Slider.d.ts.map +1 -1
  103. package/dist/StepList.d.ts +90 -0
  104. package/dist/StepList.d.ts.map +1 -0
  105. package/dist/Switch.d.ts +11 -5
  106. package/dist/Switch.d.ts.map +1 -1
  107. package/dist/Table.d.ts +222 -19
  108. package/dist/Table.d.ts.map +1 -1
  109. package/dist/Tabs.d.ts +47 -10
  110. package/dist/Tabs.d.ts.map +1 -1
  111. package/dist/TagGroup.d.ts +22 -10
  112. package/dist/TagGroup.d.ts.map +1 -1
  113. package/dist/Text.d.ts +10 -0
  114. package/dist/Text.d.ts.map +1 -0
  115. package/dist/TextField.d.ts +19 -11
  116. package/dist/TextField.d.ts.map +1 -1
  117. package/dist/TimeField.d.ts +32 -7
  118. package/dist/TimeField.d.ts.map +1 -1
  119. package/dist/Toast.d.ts +29 -14
  120. package/dist/Toast.d.ts.map +1 -1
  121. package/dist/ToggleButton.d.ts +36 -0
  122. package/dist/ToggleButton.d.ts.map +1 -0
  123. package/dist/ToggleButtonGroup.d.ts +33 -0
  124. package/dist/ToggleButtonGroup.d.ts.map +1 -0
  125. package/dist/Toolbar.d.ts +7 -3
  126. package/dist/Toolbar.d.ts.map +1 -1
  127. package/dist/Tooltip.d.ts +58 -7
  128. package/dist/Tooltip.d.ts.map +1 -1
  129. package/dist/Tree.d.ts +102 -11
  130. package/dist/Tree.d.ts.map +1 -1
  131. package/dist/Virtualizer.d.ts +61 -0
  132. package/dist/Virtualizer.d.ts.map +1 -0
  133. package/dist/VirtualizerLayouts.d.ts +82 -0
  134. package/dist/VirtualizerLayouts.d.ts.map +1 -0
  135. package/dist/VisuallyHidden.d.ts +4 -2
  136. package/dist/VisuallyHidden.d.ts.map +1 -1
  137. package/dist/contexts.d.ts +6 -1
  138. package/dist/contexts.d.ts.map +1 -1
  139. package/dist/index.d.ts +73 -39
  140. package/dist/index.d.ts.map +1 -1
  141. package/dist/index.js +23342 -10644
  142. package/dist/index.js.map +1 -7
  143. package/dist/index.jsx +18110 -0
  144. package/dist/index.jsx.map +1 -0
  145. package/dist/useDragAndDrop.d.ts +93 -0
  146. package/dist/useDragAndDrop.d.ts.map +1 -0
  147. package/dist/utils.d.ts +8 -2
  148. package/dist/utils.d.ts.map +1 -1
  149. package/dist/virtualizer/Layout.d.ts +79 -0
  150. package/dist/virtualizer/Layout.d.ts.map +1 -0
  151. package/package.json +33 -32
  152. package/src/ActionBar.tsx +251 -0
  153. package/src/ActionGroup.tsx +277 -0
  154. package/src/Alert.tsx +152 -0
  155. package/src/Autocomplete.tsx +39 -44
  156. package/src/Breadcrumbs.tsx +227 -72
  157. package/src/Button.tsx +315 -74
  158. package/src/Calendar.tsx +347 -141
  159. package/src/Checkbox.tsx +414 -123
  160. package/src/Collection.tsx +350 -0
  161. package/src/Color.tsx +1325 -284
  162. package/src/ColorEditor.tsx +213 -0
  163. package/src/ComboBox.tsx +644 -245
  164. package/src/ContextualHelpTrigger.tsx +195 -0
  165. package/src/DateField.tsx +274 -106
  166. package/src/DatePicker.tsx +892 -111
  167. package/src/DateRangePickerContext.tsx +44 -0
  168. package/src/Dialog.tsx +173 -104
  169. package/src/Disclosure.tsx +158 -105
  170. package/src/DragAndDrop.tsx +340 -0
  171. package/src/DragPreview.tsx +47 -0
  172. package/src/DropZone.tsx +233 -0
  173. package/src/FieldError.tsx +89 -0
  174. package/src/FileTrigger.tsx +83 -0
  175. package/src/Focusable.tsx +103 -0
  176. package/src/Form.tsx +140 -0
  177. package/src/GridList.tsx +542 -128
  178. package/src/HiddenDateInput.tsx +153 -0
  179. package/src/HiddenTimeInput.tsx +133 -0
  180. package/src/Icon.tsx +133 -0
  181. package/src/Keyboard.tsx +26 -0
  182. package/src/Landmark.tsx +37 -63
  183. package/src/Link.tsx +132 -69
  184. package/src/ListBox.tsx +656 -106
  185. package/src/ListDropTargetDelegate.ts +283 -0
  186. package/src/Menu.tsx +1234 -132
  187. package/src/Meter.tsx +44 -58
  188. package/src/Modal.tsx +262 -166
  189. package/src/NumberField.tsx +267 -151
  190. package/src/Popover.tsx +452 -343
  191. package/src/Pressable.tsx +108 -0
  192. package/src/ProgressBar.tsx +54 -59
  193. package/src/RadioGroup.tsx +533 -121
  194. package/src/RangeCalendar.tsx +249 -150
  195. package/src/RouterProvider.tsx +223 -0
  196. package/src/SearchField.tsx +460 -133
  197. package/src/Select.tsx +804 -233
  198. package/src/SelectionIndicator.tsx +108 -0
  199. package/src/Separator.tsx +47 -49
  200. package/src/SharedElementTransition.tsx +264 -0
  201. package/src/Slider.tsx +148 -98
  202. package/src/StepList.tsx +272 -0
  203. package/src/Switch.tsx +93 -46
  204. package/src/Table.tsx +1551 -225
  205. package/src/Tabs.tsx +377 -123
  206. package/src/TagGroup.tsx +233 -135
  207. package/src/Text.tsx +18 -0
  208. package/src/TextField.tsx +413 -86
  209. package/src/TimeField.tsx +232 -222
  210. package/src/Toast.tsx +306 -160
  211. package/src/ToggleButton.tsx +169 -0
  212. package/src/ToggleButtonGroup.tsx +141 -0
  213. package/src/Toolbar.tsx +61 -70
  214. package/src/Tooltip.tsx +473 -116
  215. package/src/Tree.tsx +1514 -175
  216. package/src/Virtualizer.tsx +730 -0
  217. package/src/VirtualizerLayouts.ts +280 -0
  218. package/src/VisuallyHidden.tsx +32 -38
  219. package/src/contexts.ts +29 -36
  220. package/src/index.ts +972 -620
  221. package/src/useDragAndDrop.ts +367 -0
  222. package/src/utils.tsx +69 -50
  223. package/src/virtualizer/Layout.ts +192 -0
  224. package/dist/index.ssr.js +0 -9785
  225. package/dist/index.ssr.js.map +0 -7
package/src/Tree.tsx CHANGED
@@ -10,22 +10,25 @@
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,
17
19
  useContext,
18
20
  For,
19
21
  Show,
20
- } from 'solid-js';
22
+ } from "solid-js";
21
23
  import {
22
24
  createTree,
23
25
  createTreeItem,
24
26
  createTreeSelectionCheckbox,
25
27
  createFocusRing,
26
28
  createHover,
29
+ mergeProps,
27
30
  type AriaTreeProps,
28
- } from '@proyecto-viviana/solidaria';
31
+ } from "@proyecto-viviana/solidaria";
29
32
  import {
30
33
  createTreeState,
31
34
  createTreeCollection,
@@ -34,7 +37,9 @@ import {
34
37
  type TreeNode,
35
38
  type TreeItemData,
36
39
  type Key,
37
- } from '@proyecto-viviana/solid-stately';
40
+ type DropTarget,
41
+ type ItemDropTarget,
42
+ } from "@proyecto-viviana/solid-stately";
38
43
  import {
39
44
  type RenderChildren,
40
45
  type ClassNameOrFunction,
@@ -42,11 +47,46 @@ import {
42
47
  type SlotProps,
43
48
  useRenderProps,
44
49
  filterDOMProps,
45
- } from './utils';
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";
72
+ import {
73
+ handleLinkClick,
74
+ type LinkDOMProps,
75
+ type RouterOptions,
76
+ useLinkProps,
77
+ useRouter,
78
+ } from "./RouterProvider";
46
79
 
47
- // ============================================
48
- // TYPES
49
- // ============================================
80
+ type RefLike<T> = ((el: T) => void) | { current?: T | null } | undefined;
81
+
82
+ function assignRef<T>(ref: RefLike<T>, el: T): void {
83
+ if (!ref) return;
84
+ if (typeof ref === "function") {
85
+ ref(el);
86
+ } else {
87
+ ref.current = el;
88
+ }
89
+ }
50
90
 
51
91
  export interface TreeRenderProps {
52
92
  /** Whether the tree has focus. */
@@ -59,19 +99,23 @@ export interface TreeRenderProps {
59
99
  isEmpty: boolean;
60
100
  }
61
101
 
62
- export interface TreeProps<T extends object> extends Omit<AriaTreeProps, 'children'>, SlotProps {
102
+ export interface TreeProps<T extends object> extends Omit<AriaTreeProps, "children">, SlotProps {
63
103
  /** The hierarchical items to render in the tree. */
64
- items: TreeItemData<T>[];
104
+ items?: CollectionEntry<TreeItemData<T>>[];
65
105
  /** The selection mode. */
66
- selectionMode?: 'none' | 'single' | 'multiple';
106
+ selectionMode?: "none" | "single" | "multiple";
107
+ /** The selection behavior (toggle vs replace). */
108
+ selectionBehavior?: "toggle" | "replace";
109
+ /** Whether disabled items can still receive focus. */
110
+ disabledBehavior?: "selection" | "all";
67
111
  /** Keys of disabled items. */
68
112
  disabledKeys?: Iterable<Key>;
69
113
  /** Currently selected keys (controlled). */
70
- selectedKeys?: 'all' | Iterable<Key>;
114
+ selectedKeys?: "all" | Iterable<Key>;
71
115
  /** Default selected keys (uncontrolled). */
72
- defaultSelectedKeys?: 'all' | Iterable<Key>;
116
+ defaultSelectedKeys?: "all" | Iterable<Key>;
73
117
  /** Handler called when selection changes. */
74
- onSelectionChange?: (keys: 'all' | Set<Key>) => void;
118
+ onSelectionChange?: (keys: "all" | Set<Key>) => void;
75
119
  /** Currently expanded keys (controlled). */
76
120
  expandedKeys?: Iterable<Key>;
77
121
  /** Default expanded keys (uncontrolled). */
@@ -86,6 +130,24 @@ export interface TreeProps<T extends object> extends Omit<AriaTreeProps, 'childr
86
130
  style?: StyleOrFunction<TreeRenderProps>;
87
131
  /** A function to render when the tree is empty. */
88
132
  renderEmptyState?: () => JSX.Element;
133
+ /** Whether there are more items to load. */
134
+ hasMore?: boolean;
135
+ /** Whether additional items are currently loading. */
136
+ isLoading?: boolean;
137
+ /** Loading state for async collection parity. */
138
+ loadingState?: "idle" | "loading" | "loadingMore" | "sorting" | "filtering" | "error";
139
+ /** Called when the load more sentinel becomes visible. */
140
+ onLoadMore?: () => void | Promise<void>;
141
+ /** Renders the load-more sentinel content. */
142
+ renderLoadMoreItem?: (state: { isLoading: boolean }) => JSX.Element;
143
+ /** CSS className for the load-more sentinel. */
144
+ loadMoreClass?: ClassNameOrFunction<{ isLoading: boolean }>;
145
+ /** Inline style for the load-more sentinel. */
146
+ loadMoreStyle?: StyleOrFunction<{ isLoading: boolean }>;
147
+ /** Drag and drop hooks from `useDragAndDrop`. */
148
+ dragAndDropHooks?: DragAndDropHooks<T>;
149
+ /** Ref for the rendered tree element. */
150
+ ref?: RefLike<HTMLDivElement>;
89
151
  }
90
152
 
91
153
  export interface TreeRenderItemState {
@@ -116,9 +178,16 @@ export interface TreeItemRenderProps {
116
178
  isExpandable: boolean;
117
179
  /** The nesting level (0 = root). */
118
180
  level: number;
181
+ /** The selection mode active on the tree. */
182
+ selectionMode: "none" | "single" | "multiple";
183
+ /** The selection behavior active on the tree. */
184
+ selectionBehavior: "toggle" | "replace";
119
185
  }
120
186
 
121
- export interface TreeItemProps<T extends object> extends SlotProps {
187
+ export interface TreeItemProps<T extends object>
188
+ extends
189
+ SlotProps,
190
+ Omit<JSX.HTMLAttributes<HTMLElement>, "class" | "style" | "children" | "id" | "ref"> {
122
191
  /** The unique key for the item. */
123
192
  id: Key;
124
193
  /** The item value. */
@@ -133,6 +202,21 @@ export interface TreeItemProps<T extends object> extends SlotProps {
133
202
  textValue?: string;
134
203
  /** Handler called when the item is activated. */
135
204
  onAction?: () => void;
205
+ /** Whether this item has children that may not be loaded yet. */
206
+ hasChildItems?: boolean;
207
+ /** Whether this item is disabled. */
208
+ isDisabled?: boolean;
209
+ /** Link target metadata. */
210
+ href?: string;
211
+ target?: LinkDOMProps["target"];
212
+ download?: LinkDOMProps["download"];
213
+ rel?: LinkDOMProps["rel"];
214
+ hrefLang?: string;
215
+ ping?: LinkDOMProps["ping"];
216
+ referrerPolicy?: LinkDOMProps["referrerPolicy"];
217
+ routerOptions?: RouterOptions;
218
+ /** Ref for the rendered row element. */
219
+ ref?: RefLike<HTMLElement>;
136
220
  }
137
221
 
138
222
  export interface TreeExpandButtonProps {
@@ -142,17 +226,548 @@ export interface TreeExpandButtonProps {
142
226
  style?: JSX.CSSProperties;
143
227
  /** Children to render inside the button. */
144
228
  children?: JSX.Element | ((props: { isExpanded: boolean }) => JSX.Element);
229
+ [key: `data-${string}`]: string | undefined;
145
230
  }
146
231
 
147
- // ============================================
148
- // CONTEXT
149
- // ============================================
232
+ export interface TreeLoadMoreItemProps extends SlotProps {
233
+ onLoadMore: () => void | Promise<void>;
234
+ isLoading?: boolean;
235
+ loadingState?: "idle" | "loading" | "loadingMore" | "sorting" | "filtering" | "error";
236
+ level?: number;
237
+ /** Scroll offset multiplier for early loading trigger (default: 1 = 100% of viewport height). */
238
+ scrollOffset?: number;
239
+ children?: JSX.Element;
240
+ class?: ClassNameOrFunction<{ isLoading: boolean }>;
241
+ style?: StyleOrFunction<{ isLoading: boolean }>;
242
+ }
243
+
244
+ export interface TreeSectionProps extends SectionProps {}
245
+ export interface TreeHeaderProps extends HeaderProps {}
150
246
 
151
247
  interface TreeContextValue<T extends object> {
152
248
  state: TreeState<T, TreeCollection<T>>;
153
249
  collection: TreeCollection<T>;
154
250
  isDisabled: boolean;
155
251
  renderItem: (item: TreeItemData<T>, state: TreeRenderItemState) => JSX.Element;
252
+ dragAndDropHooks?: DragAndDropHooks<T>;
253
+ dragState?: unknown;
254
+ dropState?: unknown;
255
+ }
256
+
257
+ interface TreeDropTargetDelegate {
258
+ getDropTargetFromPoint: (
259
+ x: number,
260
+ y: number,
261
+ isValidDropTarget: (target: DropTarget) => boolean,
262
+ ) => DropTarget | null;
263
+ getKeyboardNavigationTarget?: (
264
+ target: DropTarget | null,
265
+ direction: "next" | "previous",
266
+ isValidDropTarget: (target: DropTarget) => boolean,
267
+ ) => DropTarget | null;
268
+ getKeyboardPageNavigationTarget?: (
269
+ target: DropTarget | null,
270
+ direction: "next" | "previous",
271
+ isValidDropTarget: (target: DropTarget) => boolean,
272
+ ) => DropTarget | null;
273
+ }
274
+
275
+ interface PointerTrackingState {
276
+ lastY: number;
277
+ lastX: number;
278
+ yDirection: "up" | "down" | null;
279
+ xDirection: "left" | "right" | null;
280
+ boundaryContext: {
281
+ parentKey: Key;
282
+ lastSwitchY: number;
283
+ lastSwitchX: number;
284
+ preferredTargetIndex?: number;
285
+ } | null;
286
+ }
287
+
288
+ const X_SWITCH_THRESHOLD = 10;
289
+ const Y_SWITCH_THRESHOLD = 5;
290
+ const EXPANSION_KEYS = {
291
+ expand: { ltr: "ArrowRight", rtl: "ArrowLeft" },
292
+ collapse: { ltr: "ArrowLeft", rtl: "ArrowRight" },
293
+ } as const;
294
+
295
+ function resolveTreeDirection(element: HTMLElement | null): "ltr" | "rtl" {
296
+ if (element) {
297
+ const dir = element.closest("[dir]")?.getAttribute("dir");
298
+ if (dir === "rtl") return "rtl";
299
+ if (dir === "ltr") return "ltr";
300
+ if (typeof window !== "undefined" && typeof window.getComputedStyle === "function") {
301
+ const computedDirection = window.getComputedStyle(element).direction;
302
+ if (computedDirection === "rtl") return "rtl";
303
+ if (computedDirection === "ltr") return "ltr";
304
+ }
305
+ }
306
+ if (typeof document !== "undefined") {
307
+ return document.dir === "rtl" ? "rtl" : "ltr";
308
+ }
309
+ return "ltr";
310
+ }
311
+
312
+ function createTreeDropTargetDelegate<T extends object>(
313
+ delegate: TreeDropTargetDelegate,
314
+ state: TreeState<T, TreeCollection<T>>,
315
+ direction: "ltr" | "rtl",
316
+ baseKeyboardNav?: (
317
+ target: DropTarget | null,
318
+ direction: "next" | "previous",
319
+ isValidDropTarget: (target: DropTarget) => boolean,
320
+ ) => DropTarget | null,
321
+ ): TreeDropTargetDelegate {
322
+ const pointerTracking: PointerTrackingState = {
323
+ lastY: 0,
324
+ lastX: 0,
325
+ yDirection: null,
326
+ xDirection: null,
327
+ boundaryContext: null,
328
+ };
329
+
330
+ const getPotentialTargets = (
331
+ originalTarget: ItemDropTarget,
332
+ isValidDropTarget: (target: DropTarget) => boolean,
333
+ ): ItemDropTarget[] => {
334
+ if (originalTarget.dropPosition === "on") return [originalTarget];
335
+
336
+ const collection = state.collection;
337
+ const getNodeNextKey = (node: TreeNode<T> | null | undefined): Key | null => {
338
+ if (!node) return null;
339
+ const declaredNextKey = (node as TreeNode<T> & { nextKey?: Key | null }).nextKey;
340
+ return declaredNextKey ?? null;
341
+ };
342
+ const target: ItemDropTarget = { ...originalTarget };
343
+ let currentItem = collection.getItem(target.key);
344
+ while (currentItem && currentItem.type !== "item") {
345
+ const nextKey = getNodeNextKey(currentItem);
346
+ if (nextKey == null) break;
347
+ target.key = nextKey;
348
+ currentItem = collection.getItem(nextKey);
349
+ }
350
+
351
+ const potentialTargets: ItemDropTarget[] = [target];
352
+
353
+ if (
354
+ currentItem &&
355
+ currentItem.hasChildNodes &&
356
+ state.expandedKeys.has(currentItem.key) &&
357
+ target.dropPosition === "after"
358
+ ) {
359
+ let firstChildItemNode: TreeNode<T> | null = null;
360
+ for (const child of collection.getChildren(currentItem.key)) {
361
+ if (child.type === "item") {
362
+ firstChildItemNode = child;
363
+ break;
364
+ }
365
+ }
366
+
367
+ if (firstChildItemNode) {
368
+ const beforeFirstChildTarget: ItemDropTarget = {
369
+ type: "item",
370
+ key: firstChildItemNode.key,
371
+ dropPosition: "before",
372
+ };
373
+
374
+ if (isValidDropTarget(beforeFirstChildTarget)) {
375
+ return [beforeFirstChildTarget];
376
+ }
377
+ return [];
378
+ }
379
+ }
380
+
381
+ if (getNodeNextKey(currentItem) != null) {
382
+ return [originalTarget];
383
+ }
384
+
385
+ let parentKey = currentItem?.parentKey ?? null;
386
+ const ancestorTargets: ItemDropTarget[] = [];
387
+ while (parentKey != null) {
388
+ const parentItem = collection.getItem(parentKey);
389
+ const nextKey = getNodeNextKey(parentItem);
390
+ const nextItem = nextKey != null ? collection.getItem(nextKey) : null;
391
+ const isLastChildAtLevel = !nextItem || nextItem.parentKey !== parentKey;
392
+
393
+ if (isLastChildAtLevel) {
394
+ const afterParentTarget: ItemDropTarget = {
395
+ type: "item",
396
+ key: parentKey,
397
+ dropPosition: "after",
398
+ };
399
+
400
+ if (isValidDropTarget(afterParentTarget)) {
401
+ ancestorTargets.push(afterParentTarget);
402
+ }
403
+ if (nextItem) break;
404
+ }
405
+
406
+ parentKey = parentItem?.parentKey ?? null;
407
+ }
408
+
409
+ if (ancestorTargets.length > 0) {
410
+ potentialTargets.push(...ancestorTargets);
411
+ }
412
+
413
+ if (potentialTargets.length === 1) {
414
+ const nextKey = collection.getKeyAfter(target.key);
415
+ const nextNode = nextKey != null ? collection.getItem(nextKey) : null;
416
+ if (
417
+ nextKey != null &&
418
+ nextNode &&
419
+ currentItem &&
420
+ nextNode.level != null &&
421
+ currentItem.level != null &&
422
+ nextNode.level > currentItem.level
423
+ ) {
424
+ const beforeTarget: ItemDropTarget = {
425
+ type: "item",
426
+ key: nextKey,
427
+ dropPosition: "before",
428
+ };
429
+ if (isValidDropTarget(beforeTarget)) return [beforeTarget];
430
+ }
431
+ }
432
+
433
+ return potentialTargets.filter((candidate) => isValidDropTarget(candidate));
434
+ };
435
+
436
+ const selectTarget = (
437
+ potentialTargets: ItemDropTarget[],
438
+ originalTarget: ItemDropTarget,
439
+ x: number,
440
+ y: number,
441
+ currentYMovement: "up" | "down" | null,
442
+ currentXMovement: "left" | "right" | null,
443
+ ): ItemDropTarget => {
444
+ if (potentialTargets.length < 2) return potentialTargets[0];
445
+
446
+ const currentItem = state.collection.getItem(originalTarget.key);
447
+ const parentKey = currentItem?.parentKey;
448
+ if (parentKey == null) return potentialTargets[0];
449
+
450
+ if (
451
+ !pointerTracking.boundaryContext ||
452
+ pointerTracking.boundaryContext.parentKey !== parentKey
453
+ ) {
454
+ const initialTargetIndex =
455
+ pointerTracking.yDirection === "up" ? potentialTargets.length - 1 : 0;
456
+ pointerTracking.boundaryContext = {
457
+ parentKey,
458
+ preferredTargetIndex: initialTargetIndex,
459
+ lastSwitchY: y,
460
+ lastSwitchX: x,
461
+ };
462
+ }
463
+
464
+ const boundaryContext = pointerTracking.boundaryContext;
465
+ const distanceFromLastXSwitch = Math.abs(x - boundaryContext.lastSwitchX);
466
+ const distanceFromLastYSwitch = Math.abs(y - boundaryContext.lastSwitchY);
467
+
468
+ if (distanceFromLastYSwitch > Y_SWITCH_THRESHOLD && currentYMovement) {
469
+ const currentIndex = boundaryContext.preferredTargetIndex ?? 0;
470
+ if (currentYMovement === "down" && currentIndex === 0) {
471
+ boundaryContext.preferredTargetIndex = potentialTargets.length - 1;
472
+ } else if (currentYMovement === "up" && currentIndex === potentialTargets.length - 1) {
473
+ boundaryContext.preferredTargetIndex = 0;
474
+ }
475
+ pointerTracking.xDirection = null;
476
+ }
477
+
478
+ if (distanceFromLastXSwitch > X_SWITCH_THRESHOLD && currentXMovement) {
479
+ const currentTargetIndex = boundaryContext.preferredTargetIndex ?? 0;
480
+
481
+ if (currentXMovement === "left") {
482
+ if (direction === "ltr") {
483
+ if (currentTargetIndex < potentialTargets.length - 1) {
484
+ boundaryContext.preferredTargetIndex = currentTargetIndex + 1;
485
+ boundaryContext.lastSwitchX = x;
486
+ }
487
+ } else if (currentTargetIndex > 0) {
488
+ boundaryContext.preferredTargetIndex = currentTargetIndex - 1;
489
+ boundaryContext.lastSwitchX = x;
490
+ }
491
+ } else if (currentXMovement === "right") {
492
+ if (direction === "ltr") {
493
+ if (currentTargetIndex > 0) {
494
+ boundaryContext.preferredTargetIndex = currentTargetIndex - 1;
495
+ boundaryContext.lastSwitchX = x;
496
+ }
497
+ } else if (currentTargetIndex < potentialTargets.length - 1) {
498
+ boundaryContext.preferredTargetIndex = currentTargetIndex + 1;
499
+ boundaryContext.lastSwitchX = x;
500
+ }
501
+ }
502
+
503
+ pointerTracking.yDirection = null;
504
+ }
505
+
506
+ const targetIndex = Math.max(
507
+ 0,
508
+ Math.min(boundaryContext.preferredTargetIndex ?? 0, potentialTargets.length - 1),
509
+ );
510
+ return potentialTargets[targetIndex];
511
+ };
512
+
513
+ // --- Tree-aware keyboard DnD navigation (RAC parity) ---
514
+ const getKeyboardNavigationTarget = (
515
+ target: DropTarget | null,
516
+ dir: "next" | "previous",
517
+ isValidDropTarget: (target: DropTarget) => boolean,
518
+ ): DropTarget | null => {
519
+ const collection = state.collection;
520
+
521
+ // If the target key is not a visible row (e.g. collapsed/hidden child node),
522
+ // fall back to the base (non-override) index-based navigation to avoid infinite recursion.
523
+ // The collection keyMap contains ALL nodes (even collapsed), so check visible rows instead.
524
+ if (target && target.type === "item") {
525
+ const node = collection.getItem(target.key);
526
+ const isVisibleRow =
527
+ node != null && (node as TreeNode<T> & { rowIndex?: number }).rowIndex != null;
528
+ if (!isVisibleRow) {
529
+ return baseKeyboardNav?.(target, dir, isValidDropTarget) ?? null;
530
+ }
531
+ }
532
+
533
+ // Helpers
534
+ const tryValid = (t: DropTarget): DropTarget | null => (isValidDropTarget(t) ? t : null);
535
+
536
+ const getNodeNextKey = (node: TreeNode<T> | null | undefined): Key | null => {
537
+ if (!node) return null;
538
+ return (node as TreeNode<T> & { nextKey?: Key | null }).nextKey ?? null;
539
+ };
540
+
541
+ const isExpanded = (key: Key): boolean => {
542
+ const node = collection.getItem(key);
543
+ if (!node || !node.hasChildNodes) return false;
544
+ return state.expandedKeys.has(key);
545
+ };
546
+
547
+ const getFirstChildItemKey = (key: Key): Key | null => {
548
+ for (const child of collection.getChildren(key)) {
549
+ if (child.type === "item") return child.key;
550
+ }
551
+ return null;
552
+ };
553
+
554
+ const getLastChildItemKey = (key: Key): Key | null => {
555
+ let lastKey: Key | null = null;
556
+ for (const child of collection.getChildren(key)) {
557
+ if (child.type === "item") lastKey = child.key;
558
+ }
559
+ return lastKey;
560
+ };
561
+
562
+ // Find the deepest last expanded descendant (for "previous" from 'after')
563
+ const getDeepestLastChild = (key: Key): Key => {
564
+ let current = key;
565
+ while (isExpanded(current)) {
566
+ const lastChild = getLastChildItemKey(current);
567
+ if (lastChild == null) break;
568
+ current = lastChild;
569
+ }
570
+ return current;
571
+ };
572
+
573
+ if (dir === "next") {
574
+ // From null → root
575
+ if (!target) {
576
+ return tryValid({ type: "root" });
577
+ }
578
+ // From root → first item 'before'
579
+ if (target.type === "root") {
580
+ const firstKey = collection.getFirstKey();
581
+ if (firstKey != null) {
582
+ return tryValid({ type: "item", key: firstKey, dropPosition: "before" });
583
+ }
584
+ return null;
585
+ }
586
+ if (target.type === "item") {
587
+ switch (target.dropPosition) {
588
+ case "before":
589
+ return (
590
+ tryValid({ type: "item", key: target.key, dropPosition: "on" }) ??
591
+ tryValid({ type: "item", key: target.key, dropPosition: "after" })
592
+ );
593
+ case "on": {
594
+ // If item is expanded and has children, go to first child 'before'
595
+ if (isExpanded(target.key)) {
596
+ const firstChild = getFirstChildItemKey(target.key);
597
+ if (firstChild != null) {
598
+ return (
599
+ tryValid({ type: "item", key: firstChild, dropPosition: "before" }) ??
600
+ tryValid({ type: "item", key: firstChild, dropPosition: "on" })
601
+ );
602
+ }
603
+ }
604
+ // Otherwise, next item in collection or 'after'
605
+ const nextKey = collection.getKeyAfter(target.key);
606
+ const targetNode = collection.getItem(target.key);
607
+ const nextNode = nextKey != null ? collection.getItem(nextKey) : null;
608
+ if (
609
+ targetNode &&
610
+ nextNode &&
611
+ nextNode.level != null &&
612
+ targetNode.level != null &&
613
+ nextNode.level >= targetNode.level
614
+ ) {
615
+ return (
616
+ tryValid({ type: "item", key: nextNode.key, dropPosition: "before" }) ??
617
+ tryValid({ type: "item", key: target.key, dropPosition: "after" })
618
+ );
619
+ }
620
+ return tryValid({ type: "item", key: target.key, dropPosition: "after" });
621
+ }
622
+ case "after": {
623
+ // If item is expanded (and we're at 'after'), first child
624
+ if (isExpanded(target.key)) {
625
+ const firstChild = getFirstChildItemKey(target.key);
626
+ if (firstChild != null) {
627
+ return (
628
+ tryValid({ type: "item", key: firstChild, dropPosition: "before" }) ??
629
+ tryValid({ type: "item", key: firstChild, dropPosition: "on" })
630
+ );
631
+ }
632
+ }
633
+ // Check if this is the last sibling at its level
634
+ const targetNode = collection.getItem(target.key);
635
+ const nextSiblingKey = getNodeNextKey(targetNode);
636
+ if (nextSiblingKey != null) {
637
+ const nextSibling = collection.getItem(nextSiblingKey);
638
+ if (nextSibling?.type === "item") {
639
+ return (
640
+ tryValid({ type: "item", key: nextSibling.key, dropPosition: "before" }) ??
641
+ tryValid({ type: "item", key: nextSibling.key, dropPosition: "on" })
642
+ );
643
+ }
644
+ }
645
+ // Traverse up to parent when at last sibling
646
+ if (targetNode?.parentKey != null) {
647
+ const parentNode = collection.getItem(targetNode.parentKey);
648
+ const parentNextKey = getNodeNextKey(parentNode);
649
+ const parentNextNode =
650
+ parentNextKey != null ? collection.getItem(parentNextKey) : null;
651
+ if (parentNextNode?.type === "item") {
652
+ return tryValid({ type: "item", key: parentNextNode.key, dropPosition: "before" });
653
+ }
654
+ if (parentNode?.type === "item") {
655
+ return tryValid({ type: "item", key: parentNode.key, dropPosition: "after" });
656
+ }
657
+ }
658
+ // Reached end — try next item in flat collection
659
+ const nextKey = collection.getKeyAfter(target.key);
660
+ if (nextKey != null) {
661
+ return (
662
+ tryValid({ type: "item", key: nextKey, dropPosition: "before" }) ??
663
+ tryValid({ type: "item", key: nextKey, dropPosition: "on" })
664
+ );
665
+ }
666
+ return tryValid({ type: "root" });
667
+ }
668
+ }
669
+ }
670
+ return null;
671
+ }
672
+
673
+ // From null or root → last root-level item 'after'
674
+ if (!target || target.type === "root") {
675
+ const lastKey = collection.getLastKey();
676
+ if (lastKey != null) {
677
+ // Find root-level ancestor of last key
678
+ let rootKey = lastKey;
679
+ let node = collection.getItem(lastKey);
680
+ while (node?.parentKey != null) {
681
+ rootKey = node.parentKey;
682
+ node = collection.getItem(rootKey);
683
+ }
684
+ return tryValid({ type: "item", key: rootKey, dropPosition: "after" });
685
+ }
686
+ return null;
687
+ }
688
+
689
+ if (target.type === "item") {
690
+ switch (target.dropPosition) {
691
+ case "after": {
692
+ // If expanded with children, go to deepest last child 'after'
693
+ const deepest = getDeepestLastChild(target.key);
694
+ if (deepest !== target.key) {
695
+ return (
696
+ tryValid({ type: "item", key: deepest, dropPosition: "after" }) ??
697
+ tryValid({ type: "item", key: target.key, dropPosition: "on" })
698
+ );
699
+ }
700
+ return tryValid({ type: "item", key: target.key, dropPosition: "on" });
701
+ }
702
+ case "on":
703
+ return tryValid({ type: "item", key: target.key, dropPosition: "before" });
704
+ case "before": {
705
+ // Move to the previous sibling's deepest last child 'after'
706
+ const prevKey = collection.getKeyBefore(target.key);
707
+ if (prevKey != null) {
708
+ const deepest = getDeepestLastChild(prevKey);
709
+ return (
710
+ tryValid({ type: "item", key: deepest, dropPosition: "after" }) ??
711
+ tryValid({ type: "item", key: prevKey, dropPosition: "on" })
712
+ );
713
+ }
714
+ // No previous — go to root
715
+ return tryValid({ type: "root" });
716
+ }
717
+ }
718
+ }
719
+
720
+ return null;
721
+ };
722
+
723
+ return {
724
+ getDropTargetFromPoint(x, y, isValidDropTarget) {
725
+ const baseTarget = delegate.getDropTargetFromPoint(x, y, isValidDropTarget);
726
+ if (!baseTarget || baseTarget.type === "root") return baseTarget;
727
+
728
+ const deltaY = y - pointerTracking.lastY;
729
+ const deltaX = x - pointerTracking.lastX;
730
+ let currentYMovement: "up" | "down" | null = pointerTracking.yDirection;
731
+ let currentXMovement: "left" | "right" | null = pointerTracking.xDirection;
732
+
733
+ if (Math.abs(deltaY) > Y_SWITCH_THRESHOLD) {
734
+ currentYMovement = deltaY > 0 ? "down" : "up";
735
+ pointerTracking.yDirection = currentYMovement;
736
+ pointerTracking.lastY = y;
737
+ }
738
+
739
+ if (Math.abs(deltaX) > X_SWITCH_THRESHOLD) {
740
+ currentXMovement = deltaX > 0 ? "right" : "left";
741
+ pointerTracking.xDirection = currentXMovement;
742
+ pointerTracking.lastX = x;
743
+ }
744
+
745
+ let target: ItemDropTarget = baseTarget;
746
+ if (target.dropPosition === "before") {
747
+ const keyBefore = state.collection.getKeyBefore(target.key);
748
+ if (keyBefore != null) {
749
+ const normalized: ItemDropTarget = {
750
+ type: "item",
751
+ key: keyBefore,
752
+ dropPosition: "after",
753
+ };
754
+ if (isValidDropTarget(normalized)) target = normalized;
755
+ }
756
+ }
757
+
758
+ const potentialTargets = getPotentialTargets(target, isValidDropTarget);
759
+ if (potentialTargets.length === 0) return { type: "root" };
760
+
761
+ if (potentialTargets.length > 1) {
762
+ return selectTarget(potentialTargets, target, x, y, currentYMovement, currentXMovement);
763
+ }
764
+
765
+ pointerTracking.boundaryContext = null;
766
+ return potentialTargets[0];
767
+ },
768
+ getKeyboardNavigationTarget,
769
+ getKeyboardPageNavigationTarget: delegate.getKeyboardPageNavigationTarget?.bind(delegate),
770
+ };
156
771
  }
157
772
 
158
773
  interface TreeItemContextValue<T extends object> {
@@ -163,12 +778,33 @@ interface TreeItemContextValue<T extends object> {
163
778
  }
164
779
 
165
780
  export const TreeContext = createContext<TreeContextValue<object> | null>(null);
166
- export const TreeStateContext = createContext<TreeState<object, TreeCollection<object>> | null>(null);
781
+ export const TreeStateContext = createContext<TreeState<object, TreeCollection<object>> | null>(
782
+ null,
783
+ );
167
784
  export const TreeItemContext = createContext<TreeItemContextValue<object> | null>(null);
785
+ const TreeItemContentContext = createContext<TreeItemRenderProps | null>(null);
168
786
 
169
- // ============================================
170
- // COMPONENTS
171
- // ============================================
787
+ function isTreeItemRecord(value: unknown): value is Record<PropertyKey, unknown> {
788
+ return typeof value === "object" && value !== null;
789
+ }
790
+
791
+ function treeItemDataFromNode<T extends object>(node: TreeNode<T>): TreeItemData<T> {
792
+ const value = node.value;
793
+ const item: Record<PropertyKey, unknown> = isTreeItemRecord(value) ? value : {};
794
+ return {
795
+ ...item,
796
+ key: node.key,
797
+ id: (item.id as Key | undefined) ?? node.key,
798
+ value: (value ?? undefined) as T | undefined,
799
+ textValue: node.textValue,
800
+ isDisabled: node.isDisabled,
801
+ hasChildItems: node.hasChildNodes,
802
+ children:
803
+ node.childNodes.length > 0
804
+ ? node.childNodes.map((child) => treeItemDataFromNode(child))
805
+ : undefined,
806
+ };
807
+ }
172
808
 
173
809
  /**
174
810
  * A tree displays hierarchical data with expandable/collapsible nodes,
@@ -177,29 +813,51 @@ export const TreeItemContext = createContext<TreeItemContextValue<object> | null
177
813
  export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
178
814
  const [local, stateProps, ariaProps] = splitProps(
179
815
  props,
180
- ['class', 'style', 'slot', 'renderEmptyState'],
181
816
  [
182
- 'items',
183
- 'disabledKeys',
184
- 'selectionMode',
185
- 'selectedKeys',
186
- 'defaultSelectedKeys',
187
- 'onSelectionChange',
188
- 'expandedKeys',
189
- 'defaultExpandedKeys',
190
- 'onExpandedChange',
191
- ]
817
+ "class",
818
+ "style",
819
+ "slot",
820
+ "renderEmptyState",
821
+ "hasMore",
822
+ "isLoading",
823
+ "loadingState",
824
+ "onLoadMore",
825
+ "renderLoadMoreItem",
826
+ "loadMoreClass",
827
+ "loadMoreStyle",
828
+ "dragAndDropHooks",
829
+ "ref",
830
+ ],
831
+ [
832
+ "items",
833
+ "disabledKeys",
834
+ "disabledBehavior",
835
+ "selectionMode",
836
+ "selectionBehavior",
837
+ "selectedKeys",
838
+ "defaultSelectedKeys",
839
+ "onSelectionChange",
840
+ "expandedKeys",
841
+ "defaultExpandedKeys",
842
+ "onExpandedChange",
843
+ ],
192
844
  );
193
845
 
194
- // Create ref signal
195
846
  const [ref, setRef] = createSignal<HTMLDivElement | null>(null);
847
+ const flatItems = createMemo<TreeItemData<T>[]>(() =>
848
+ flattenCollectionEntries(stateProps.items ?? []),
849
+ );
850
+ const hasSections = createMemo(() =>
851
+ (stateProps.items ?? []).some((entry) => isCollectionSection(entry)),
852
+ );
196
853
 
197
- // Create tree state
198
854
  const state = createTreeState<T, TreeCollection<T>>(() => ({
199
855
  collectionFactory: (expandedKeys) =>
200
- createTreeCollection(stateProps.items, expandedKeys) as TreeCollection<T>,
856
+ createTreeCollection(flatItems(), expandedKeys) as TreeCollection<T>,
201
857
  disabledKeys: stateProps.disabledKeys,
858
+ disabledBehavior: stateProps.disabledBehavior,
202
859
  selectionMode: stateProps.selectionMode,
860
+ selectionBehavior: stateProps.selectionBehavior,
203
861
  selectedKeys: stateProps.selectedKeys,
204
862
  defaultSelectedKeys: stateProps.defaultSelectedKeys,
205
863
  onSelectionChange: stateProps.onSelectionChange,
@@ -208,49 +866,60 @@ export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
208
866
  onExpandedChange: stateProps.onExpandedChange,
209
867
  }));
210
868
 
211
- // Create tree aria props
869
+ const [lastExpandedKeys, setLastExpandedKeys] = createSignal<Set<Key>>(new Set());
870
+ const [lastItemsLength, setLastItemsLength] = createSignal(flatItems().length);
871
+ const [collectionVersion, setCollectionVersion] = createSignal(0);
872
+ createEffect(() => {
873
+ const expanded = state.expandedKeys;
874
+ const items = flatItems();
875
+ if (!areSetsEqual(lastExpandedKeys(), expanded) || lastItemsLength() !== items.length) {
876
+ setLastExpandedKeys(new Set(expanded));
877
+ setLastItemsLength(items.length);
878
+ setCollectionVersion((v) => v + 1);
879
+ }
880
+ });
881
+
882
+ // Resolve writing direction for keyboard expand/collapse parity
883
+ const treeDirection = createMemo(() => ariaProps.direction ?? resolveTreeDirection(ref()));
884
+
212
885
  const { treeProps } = createTree<T, TreeCollection<T>>(
213
886
  () => ({
214
887
  id: ariaProps.id,
215
- 'aria-label': ariaProps['aria-label'],
216
- 'aria-labelledby': ariaProps['aria-labelledby'],
217
- 'aria-describedby': ariaProps['aria-describedby'],
888
+ "aria-label": ariaProps["aria-label"],
889
+ "aria-labelledby": ariaProps["aria-labelledby"],
890
+ "aria-describedby": ariaProps["aria-describedby"],
218
891
  isVirtualized: ariaProps.isVirtualized,
219
892
  onAction: ariaProps.onAction,
220
893
  isDisabled: ariaProps.isDisabled,
894
+ direction: treeDirection(),
221
895
  }),
222
896
  () => state,
223
- ref
897
+ ref,
224
898
  );
225
899
 
226
- // Create focus ring
227
900
  const { isFocused, isFocusVisible, focusProps } = createFocusRing();
228
901
 
229
- // Render props values
230
902
  const renderValues = createMemo<TreeRenderProps>(() => ({
231
903
  isFocused: state.isFocused || isFocused(),
232
904
  isFocusVisible: isFocusVisible(),
233
905
  isDisabled: ariaProps.isDisabled ?? false,
234
- isEmpty: stateProps.items.length === 0,
906
+ isEmpty: flatItems().length === 0,
235
907
  }));
236
908
 
237
- // Resolve render props
238
909
  const renderProps = useRenderProps(
239
910
  {
240
911
  class: local.class,
241
912
  style: local.style,
242
- defaultClassName: 'solidaria-Tree',
913
+ defaultClassName: "solidaria-Tree",
243
914
  },
244
- renderValues
915
+ renderValues,
245
916
  );
246
917
 
247
- // Filter DOM props
248
918
  const domProps = createMemo(() => {
249
919
  const filtered = filterDOMProps(ariaProps as Record<string, unknown>, { global: true });
250
920
  return filtered;
251
921
  });
252
922
 
253
- // Remove ref from spread props
254
923
  const cleanTreeProps = () => {
255
924
  const { ref: _ref1, ...rest } = treeProps as Record<string, unknown>;
256
925
  return rest;
@@ -260,61 +929,502 @@ export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
260
929
  return rest;
261
930
  };
262
931
 
263
- const isEmpty = () => stateProps.items.length === 0;
932
+ const isEmpty = () => flatItems().length === 0;
933
+
934
+ const visibleRows = createMemo(() => {
935
+ collectionVersion();
936
+ return state.collection.rows;
937
+ });
938
+ const virtualizer = useVirtualizerContext();
939
+ const parentCollectionRenderer = useCollectionRenderer<TreeItemData<T>>();
940
+ const getDropTargetByIndex = (
941
+ index: number,
942
+ position: "before" | "after" | "on",
943
+ ): DropTarget | null => {
944
+ const node = visibleRows()[index];
945
+ if (!node) return null;
946
+ return { type: "item", key: node.key, dropPosition: position };
947
+ };
948
+ const hasDroppableDnd = createMemo(() => {
949
+ const hooks = local.dragAndDropHooks;
950
+ return Boolean(
951
+ hooks?.useDroppableCollectionState &&
952
+ hooks.useDroppableCollection &&
953
+ (hooks.dropTargetDelegate ||
954
+ parentCollectionRenderer?.dropTargetDelegate ||
955
+ hooks.ListDropTargetDelegate),
956
+ );
957
+ });
958
+ const hasDraggableDnd = createMemo(() => {
959
+ const hooks = local.dragAndDropHooks;
960
+ return Boolean(hooks?.useDraggableCollectionState && hooks.useDraggableCollection);
961
+ });
962
+ const dragState = createMemo(() => {
963
+ if (!hasDraggableDnd()) return undefined;
964
+ return local.dragAndDropHooks?.useDraggableCollectionState?.({
965
+ items: visibleRows().map((node) => node.value as T),
966
+ });
967
+ });
968
+ const dropState = createMemo(() => {
969
+ if (!hasDroppableDnd()) return undefined;
970
+ return local.dragAndDropHooks?.useDroppableCollectionState?.({});
971
+ });
972
+ createEffect(() => {
973
+ const activeDropState = dropState();
974
+ if (!activeDropState) return;
975
+ const originalGetDropOperation = activeDropState.getDropOperation.bind(activeDropState);
976
+
977
+ activeDropState.getDropOperation = (target, types, allowedOperations) => {
978
+ const currentDraggingKeys = dragState()?.draggingKeys ?? new Set<string | number>();
979
+ if (target.type === "item" && currentDraggingKeys.size > 0) {
980
+ if (currentDraggingKeys.has(target.key) && target.dropPosition === "on") {
981
+ return "cancel";
982
+ }
983
+
984
+ let currentKey: Key | null = target.key;
985
+ while (currentKey != null) {
986
+ const item = state.collection.getItem(currentKey);
987
+ const parentKey = item?.parentKey;
988
+ if (parentKey != null && currentDraggingKeys.has(parentKey)) {
989
+ return "cancel";
990
+ }
991
+ currentKey = parentKey ?? null;
992
+ }
993
+ }
994
+
995
+ return originalGetDropOperation(target, types, allowedOperations);
996
+ };
264
997
 
998
+ onCleanup(() => {
999
+ activeDropState.getDropOperation = originalGetDropOperation;
1000
+ });
1001
+ });
1002
+ createEffect(() => {
1003
+ if (!hasDraggableDnd()) return;
1004
+ const hooks = local.dragAndDropHooks;
1005
+ const activeDragState = dragState();
1006
+ if (!hooks?.useDraggableCollection || !activeDragState) return;
1007
+ hooks.useDraggableCollection({}, activeDragState, () => ref());
1008
+ });
265
1009
  const contextValue = createMemo<TreeContextValue<T>>(() => ({
266
1010
  state,
267
1011
  collection: state.collection,
268
1012
  isDisabled: ariaProps.isDisabled ?? false,
269
1013
  renderItem: props.children,
1014
+ dragAndDropHooks: local.dragAndDropHooks,
1015
+ dragState: dragState(),
1016
+ dropState: dropState(),
270
1017
  }));
1018
+ const droppableCollection = createMemo(() => {
1019
+ if (!hasDroppableDnd()) return undefined;
1020
+ const hooks = local.dragAndDropHooks;
1021
+ const activeDropState = dropState();
1022
+ if (!hooks?.useDroppableCollection || !activeDropState) return undefined;
1023
+ const direction = resolveTreeDirection(ref());
1024
+ const baseDropTargetDelegate =
1025
+ hooks.dropTargetDelegate ??
1026
+ parentCollectionRenderer?.dropTargetDelegate ??
1027
+ (hooks.ListDropTargetDelegate
1028
+ ? new hooks.ListDropTargetDelegate(
1029
+ () => state.collection,
1030
+ () => ref(),
1031
+ { layout: "stack", orientation: "vertical", direction },
1032
+ )
1033
+ : undefined);
1034
+ if (!baseDropTargetDelegate) return undefined;
1035
+ const dropTargetDelegate = createTreeDropTargetDelegate(
1036
+ baseDropTargetDelegate as TreeDropTargetDelegate,
1037
+ state,
1038
+ direction,
1039
+ virtualizer?.getBaseKeyboardNavigationTarget,
1040
+ );
1041
+ return hooks.useDroppableCollection(
1042
+ {
1043
+ dropTargetDelegate,
1044
+ keyboardDelegate: {
1045
+ getFirstKey: () => state.collection.getFirstKey(),
1046
+ getLastKey: () => state.collection.getLastKey(),
1047
+ getKeyBelow: (key) => state.collection.getKeyAfter(key),
1048
+ getKeyAbove: (key) => state.collection.getKeyBefore(key),
1049
+ getKeyPageBelow: (key) => state.collection.getKeyAfter(key),
1050
+ getKeyPageAbove: (key) => state.collection.getKeyBefore(key),
1051
+ },
1052
+ get collection() {
1053
+ return state.collection;
1054
+ },
1055
+ get selectedKeys() {
1056
+ return state.selectedKeys;
1057
+ },
1058
+ setSelectedKeys: (keys: Set<Key>) => {
1059
+ if (state.selectionMode === "none") return;
1060
+ state.clearSelection();
1061
+ for (const key of keys) {
1062
+ state.toggleSelection(key);
1063
+ }
1064
+ },
1065
+ setFocusedKey: (key) => state.setFocusedKey(key),
1066
+ onDropActivate: (event) => {
1067
+ if (event.target.type !== "item") return;
1068
+ const key = event.target.key;
1069
+ const item = state.collection.getItem(key);
1070
+ const isExpanded = state.isExpanded(key);
1071
+ if (item?.hasChildNodes && (!isExpanded || hooks.isVirtualDragging?.())) {
1072
+ state.toggleKey(key);
1073
+ }
1074
+ },
1075
+ onKeyDown: (event) => {
1076
+ const target = activeDropState.target;
1077
+ if (!target || target.type !== "item" || target.dropPosition !== "on") return;
1078
+ const item = state.collection.getItem(target.key);
1079
+ if (!item?.hasChildNodes) return;
1080
+ const currentDirection = ariaProps.direction ?? resolveTreeDirection(ref());
1081
+ const expandKey = EXPANSION_KEYS.expand[currentDirection];
1082
+ const collapseKey = EXPANSION_KEYS.collapse[currentDirection];
1083
+ if (event.key === expandKey && !state.isExpanded(target.key)) {
1084
+ state.toggleKey(target.key);
1085
+ } else if (event.key === collapseKey && state.isExpanded(target.key)) {
1086
+ state.toggleKey(target.key);
1087
+ }
1088
+ },
1089
+ },
1090
+ activeDropState,
1091
+ () => ref(),
1092
+ );
1093
+ });
1094
+ const isRootDropTarget = createMemo(() => {
1095
+ return Boolean(dropState()?.target?.type === "root");
1096
+ });
1097
+ const dndRenderDropIndicator = createMemo(() =>
1098
+ useRenderDropIndicator(local.dragAndDropHooks, dropState()),
1099
+ );
1100
+ const dndDropIndicator = (index: number, position: "before" | "after" | "on") => {
1101
+ const target = getDropTargetByIndex(index, position);
1102
+ if (!target || target.type !== "item") return undefined;
1103
+ return dndRenderDropIndicator()?.(target);
1104
+ };
1105
+ const persistedKeys = useDndPersistedKeys(
1106
+ { focusedKey: () => state.focusedKey },
1107
+ local.dragAndDropHooks,
1108
+ dropState(),
1109
+ state.collection,
1110
+ );
1111
+ const virtualRange = createMemo(() => {
1112
+ if (!virtualizer || !parentCollectionRenderer?.isVirtualized) return null;
1113
+ const rows = visibleRows();
1114
+ const baseRange = virtualizer.getVisibleRange(rows.length);
1115
+ const persistedIndexes = Array.from(persistedKeys())
1116
+ .map((key) => rows.findIndex((node) => node.key === key))
1117
+ .filter((index) => index >= 0);
1118
+ const dropTarget = dropState()?.target;
1119
+ const normalizedDropKey = getNormalizedDropTargetKey(dropTarget, state.collection);
1120
+ const focusedKey = state.focusedKey;
1121
+ const focusedIndex =
1122
+ focusedKey != null ? rows.findIndex((node) => node.key === focusedKey) : -1;
1123
+ const forceIncludeIndexes = [
1124
+ dropTarget?.type === "item" ? rows.findIndex((node) => node.key === dropTarget.key) : -1,
1125
+ normalizedDropKey != null ? rows.findIndex((node) => node.key === normalizedDropKey) : -1,
1126
+ dropTarget?.type === "item" ? -1 : focusedIndex,
1127
+ ].filter((index) => index >= 0);
1128
+ return mergePersistedKeysIntoVirtualRange(
1129
+ baseRange,
1130
+ persistedIndexes,
1131
+ rows.length,
1132
+ virtualizer,
1133
+ 80,
1134
+ {
1135
+ forceIncludeIndexes,
1136
+ forceIncludeMaxSpan: 320,
1137
+ },
1138
+ );
1139
+ });
1140
+ const virtualizedVisibleRows = createMemo(() => {
1141
+ const range = virtualRange();
1142
+ if (!range) return visibleRows();
1143
+ return visibleRows().slice(range.start, range.end);
1144
+ });
1145
+ createEffect(() => {
1146
+ if (!virtualizer || !parentCollectionRenderer?.isVirtualized) return;
1147
+ virtualizer.setDropTargetItemCountResolver(() => visibleRows().length);
1148
+ virtualizer.setDropTargetIndexResolver((key) => {
1149
+ const rows = visibleRows();
1150
+ const index = rows.findIndex((node) => node.key === key);
1151
+ return index >= 0 ? index : null;
1152
+ });
1153
+ virtualizer.setDropTargetResolver((target) => {
1154
+ const node = visibleRows()[target.index];
1155
+ if (!node) return target;
1156
+ return {
1157
+ ...target,
1158
+ key: typeof node.key === "string" || typeof node.key === "number" ? node.key : undefined,
1159
+ parentKey:
1160
+ typeof node.parentKey === "string" || typeof node.parentKey === "number"
1161
+ ? node.parentKey
1162
+ : node.parentKey == null
1163
+ ? null
1164
+ : undefined,
1165
+ level: typeof node.level === "number" ? node.level : undefined,
1166
+ };
1167
+ });
1168
+ onCleanup(() => {
1169
+ virtualizer.setDropTargetIndexResolver(undefined);
1170
+ virtualizer.setDropTargetItemCountResolver(undefined);
1171
+ virtualizer.setDropTargetResolver(undefined);
1172
+ });
1173
+ });
1174
+ const rowIndexByKey = createMemo(() => {
1175
+ const map = new Map<Key, number>();
1176
+ const rows = visibleRows();
1177
+ for (let i = 0; i < rows.length; i += 1) {
1178
+ map.set(rows[i].key, i);
1179
+ }
1180
+ return map;
1181
+ });
1182
+ const getAfterIndicatorIndexes = (
1183
+ absoluteIndex: number,
1184
+ renderRange?: { start: number; end: number } | null,
1185
+ ): number[] => {
1186
+ const rows = visibleRows();
1187
+ const current = rows[absoluteIndex];
1188
+ if (!current) return [];
1189
+ const next = rows[absoluteIndex + 1];
1190
+ // "after" is equivalent to next sibling's "before" when next row is at same or deeper level.
1191
+ if (next && next.level >= current.level) {
1192
+ return [];
1193
+ }
1194
+
1195
+ const result: number[] = [];
1196
+ let cursorIndex: number | null = absoluteIndex;
271
1197
 
272
- // Render visible rows (flat list based on expansion state)
273
- const visibleRows = createMemo(() => state.collection.rows);
1198
+ // Emit after indicators for current and ancestor boundary levels, matching RAC branch exit semantics.
1199
+ while (cursorIndex != null) {
1200
+ const cursor: TreeNode<T> | undefined = rows[cursorIndex];
1201
+ if (!cursor) break;
1202
+ const shouldRender =
1203
+ !next || (cursor.parentKey !== next.parentKey && next.level < cursor.level);
1204
+ if (!shouldRender) break;
1205
+ result.push(cursorIndex);
1206
+ if (cursor.parentKey == null) break;
1207
+ cursorIndex = rowIndexByKey().get(cursor.parentKey) ?? null;
1208
+ }
1209
+ if (!renderRange) return result;
1210
+ return result.filter((index) => index >= renderRange.start && index < renderRange.end);
1211
+ };
1212
+ // Install tree-aware keyboard navigation override into the Virtualizer (if present).
1213
+ // This replaces the generic index-based navigation with collection-level semantics
1214
+ // (tree branch traversal, level-aware wrapping — RAC parity item #36).
1215
+ createEffect(() => {
1216
+ if (!virtualizer) return;
1217
+ const direction = resolveTreeDirection(ref());
1218
+ const parentDelegate: TreeDropTargetDelegate = {
1219
+ getDropTargetFromPoint:
1220
+ parentCollectionRenderer?.dropTargetDelegate?.getDropTargetFromPoint ??
1221
+ ((_x, _y, _v) => null),
1222
+ getKeyboardNavigationTarget:
1223
+ parentCollectionRenderer?.dropTargetDelegate?.getKeyboardNavigationTarget,
1224
+ getKeyboardPageNavigationTarget:
1225
+ parentCollectionRenderer?.dropTargetDelegate?.getKeyboardPageNavigationTarget,
1226
+ };
1227
+ const treeDelegate = createTreeDropTargetDelegate(
1228
+ parentDelegate,
1229
+ state,
1230
+ direction,
1231
+ virtualizer.getBaseKeyboardNavigationTarget,
1232
+ );
1233
+ virtualizer.setKeyboardNavigationOverride(
1234
+ treeDelegate.getKeyboardNavigationTarget
1235
+ ? (target, dir, isValid) => treeDelegate.getKeyboardNavigationTarget!(target, dir, isValid)
1236
+ : undefined,
1237
+ );
1238
+ onCleanup(() => {
1239
+ virtualizer.setKeyboardNavigationOverride(undefined);
1240
+ });
1241
+ });
1242
+ const collectionRenderer = createMemo<CollectionRendererContextValue<unknown>>(() => ({
1243
+ ...parentCollectionRenderer,
1244
+ renderItem: (item) => item as JSX.Element,
1245
+ renderDropIndicator: (index: number, position: "before" | "after" | "on") =>
1246
+ dndDropIndicator(index, position) ??
1247
+ parentCollectionRenderer?.renderDropIndicator?.(index, position),
1248
+ }));
1249
+ const rootKeyByNodeKey = createMemo(() => {
1250
+ const rootMap = new Map<Key, Key>();
1251
+ for (const row of visibleRows()) {
1252
+ let rootKey: Key = row.key;
1253
+ let parentKey = row.parentKey;
1254
+ while (parentKey != null) {
1255
+ rootKey = parentKey;
1256
+ parentKey = state.collection.getParentKey(parentKey);
1257
+ }
1258
+ rootMap.set(row.key, rootKey);
1259
+ }
1260
+ return rootMap;
1261
+ });
1262
+ const renderRange = createMemo(() => {
1263
+ const range = virtualRange();
1264
+ if (!range) return null;
1265
+ return { start: range.start, end: range.end };
1266
+ });
1267
+ const renderableRows = createMemo(() => {
1268
+ const offset = renderRange()?.start ?? 0;
1269
+ return virtualizedVisibleRows().map((node, index) => ({
1270
+ node,
1271
+ globalIndex: offset + index,
1272
+ }));
1273
+ });
1274
+ const sectionedRenderableRows = createMemo(() => {
1275
+ if (!hasSections()) return null;
1276
+ const rootMap = rootKeyByNodeKey();
1277
+ const rows = renderableRows();
1278
+ return (stateProps.items ?? []).map((entry) => {
1279
+ if (!isCollectionSection(entry)) {
1280
+ const matching = rows.filter((row) => rootMap.get(row.node.key) === entry.key);
1281
+ return {
1282
+ type: "single" as const,
1283
+ item: entry,
1284
+ rows: matching,
1285
+ };
1286
+ }
1287
+ const sectionRootKeys = new Set(entry.items.map((item) => item.key));
1288
+ const sectionRows = rows.filter((row) => {
1289
+ const rootKey = rootMap.get(row.node.key);
1290
+ return rootKey != null && sectionRootKeys.has(rootKey);
1291
+ });
1292
+ return {
1293
+ type: "section" as const,
1294
+ section: entry,
1295
+ rows: sectionRows,
1296
+ };
1297
+ });
1298
+ });
1299
+ const renderTreeRow = (node: TreeNode<T>, itemIndex: number) => {
1300
+ const beforeIndicator = () => collectionRenderer().renderDropIndicator?.(itemIndex, "before");
1301
+ const onIndicator = () => collectionRenderer().renderDropIndicator?.(itemIndex, "on");
1302
+ const afterIndicatorIndexes = () => getAfterIndicatorIndexes(itemIndex, renderRange());
1303
+ const itemData = treeItemDataFromNode(node);
1304
+ const itemState: TreeRenderItemState = {
1305
+ isExpanded: node.isExpanded ?? false,
1306
+ isExpandable: node.isExpandable ?? false,
1307
+ level: node.level,
1308
+ };
1309
+ return (
1310
+ <>
1311
+ {beforeIndicator()}
1312
+ {onIndicator()}
1313
+ {props.children(itemData, itemState)}
1314
+ <For each={afterIndicatorIndexes()}>
1315
+ {(afterIndex) => collectionRenderer().renderDropIndicator?.(afterIndex, "after")}
1316
+ </For>
1317
+ </>
1318
+ );
1319
+ };
274
1320
 
275
1321
  return (
276
1322
  <TreeContext.Provider value={contextValue() as unknown as TreeContextValue<object>}>
277
- <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>
1323
+ <TreeStateContext.Provider
1324
+ value={state as unknown as TreeState<object, TreeCollection<object>>}
1325
+ >
1326
+ <CollectionRendererContext.Provider value={collectionRenderer()}>
1327
+ <div
1328
+ ref={(element) => {
1329
+ setRef(element);
1330
+ assignRef(local.ref, element);
1331
+ }}
1332
+ {...mergeProps(
1333
+ domProps(),
1334
+ cleanTreeProps(),
1335
+ cleanFocusProps(),
1336
+ (droppableCollection()?.collectionProps as Record<string, unknown> | undefined) ?? {},
1337
+ )}
1338
+ class={renderProps.class()}
1339
+ style={renderProps.style()}
1340
+ data-focused={state.isFocused || undefined}
1341
+ data-focus-visible={isFocusVisible() || undefined}
1342
+ data-disabled={ariaProps.isDisabled || undefined}
1343
+ data-empty={isEmpty() || undefined}
1344
+ data-drop-target={isRootDropTarget() || undefined}
1345
+ data-selection-mode={
1346
+ stateProps.selectionMode !== "none" ? stateProps.selectionMode : undefined
1347
+ }
1348
+ data-allows-dragging={hasDraggableDnd() || undefined}
1349
+ >
1350
+ <SharedElementTransition>
1351
+ {isEmpty() && local.renderEmptyState ? (
1352
+ <div role="row" aria-level={1} style={{ display: "contents" }}>
1353
+ <div role="gridcell" style={{ display: "contents" }}>
1354
+ {local.renderEmptyState()}
1355
+ </div>
1356
+ </div>
1357
+ ) : (
1358
+ <>
1359
+ {virtualRange()?.offsetTop ? (
1360
+ <div
1361
+ role="presentation"
1362
+ aria-hidden="true"
1363
+ style={{ height: `${virtualRange()!.offsetTop}px` }}
1364
+ data-virtualizer-spacer="top"
1365
+ />
1366
+ ) : null}
1367
+ <Show
1368
+ when={hasSections()}
1369
+ fallback={
1370
+ <For each={renderableRows()}>
1371
+ {(row) => renderTreeRow(row.node, row.globalIndex)}
1372
+ </For>
1373
+ }
1374
+ >
1375
+ <For each={sectionedRenderableRows() ?? []}>
1376
+ {(entry) => (
1377
+ <Show when={entry.rows.length > 0}>
1378
+ <Show
1379
+ when={entry.type === "section"}
1380
+ fallback={
1381
+ <For each={entry.rows}>
1382
+ {(row) => renderTreeRow(row.node, row.globalIndex)}
1383
+ </For>
1384
+ }
1385
+ >
1386
+ <TreeSection>
1387
+ {entry.type === "section" && entry.section.title ? (
1388
+ <TreeHeader>{entry.section.title}</TreeHeader>
1389
+ ) : null}
1390
+ <For each={entry.rows}>
1391
+ {(row) => renderTreeRow(row.node, row.globalIndex)}
1392
+ </For>
1393
+ </TreeSection>
1394
+ </Show>
1395
+ </Show>
1396
+ )}
1397
+ </For>
1398
+ </Show>
1399
+ {virtualRange()?.offsetBottom ? (
1400
+ <div
1401
+ role="presentation"
1402
+ aria-hidden="true"
1403
+ style={{ height: `${virtualRange()!.offsetBottom}px` }}
1404
+ data-virtualizer-spacer="bottom"
1405
+ />
1406
+ ) : null}
1407
+ </>
1408
+ )}
1409
+ </SharedElementTransition>
1410
+ {local.hasMore && local.onLoadMore && (
1411
+ <TreeLoadMoreItem
1412
+ onLoadMore={local.onLoadMore}
1413
+ isLoading={local.isLoading}
1414
+ loadingState={local.loadingState}
1415
+ class={local.loadMoreClass}
1416
+ style={local.loadMoreStyle}
1417
+ >
1418
+ {local.renderLoadMoreItem?.({
1419
+ isLoading:
1420
+ !!local.isLoading ||
1421
+ local.loadingState === "loading" ||
1422
+ local.loadingState === "loadingMore",
1423
+ })}
1424
+ </TreeLoadMoreItem>
1425
+ )}
1426
+ </div>
1427
+ </CollectionRendererContext.Provider>
318
1428
  </TreeStateContext.Provider>
319
1429
  </TreeContext.Provider>
320
1430
  );
@@ -324,107 +1434,147 @@ export function Tree<T extends object>(props: TreeProps<T>): JSX.Element {
324
1434
  * An item in a tree.
325
1435
  */
326
1436
  export function TreeItem<T extends object>(props: TreeItemProps<T>): JSX.Element {
327
- const [local] = splitProps(props, [
328
- 'class',
329
- 'style',
330
- 'slot',
331
- 'id',
332
- 'item',
333
- 'textValue',
334
- 'onAction',
1437
+ const [local, domProps] = splitProps(props, [
1438
+ "class",
1439
+ "style",
1440
+ "slot",
1441
+ "id",
1442
+ "item",
1443
+ "textValue",
1444
+ "onAction",
1445
+ "hasChildItems",
1446
+ "isDisabled",
1447
+ "href",
1448
+ "target",
1449
+ "download",
1450
+ "rel",
1451
+ "hrefLang",
1452
+ "ping",
1453
+ "referrerPolicy",
1454
+ "routerOptions",
1455
+ "ref",
1456
+ "children",
335
1457
  ]);
336
1458
 
337
- // Get state from context
338
1459
  const context = useContext(TreeStateContext);
339
1460
  if (!context) {
340
- throw new Error('TreeItem must be used within a Tree');
1461
+ throw new Error("TreeItem must be used within a Tree");
341
1462
  }
342
1463
  const state = context as TreeState<T, TreeCollection<T>>;
1464
+ const treeContext = useContext(TreeContext) as TreeContextValue<T> | null;
1465
+ const router = useRouter();
1466
+ const linkProps = createMemo(() =>
1467
+ useLinkProps({
1468
+ href: local.href,
1469
+ target: local.target,
1470
+ rel: local.rel,
1471
+ download: local.download,
1472
+ ping: local.ping,
1473
+ referrerPolicy: local.referrerPolicy,
1474
+ }),
1475
+ );
343
1476
 
344
- // Create ref signal
345
- const [ref, setRef] = createSignal<HTMLDivElement | null>(null);
1477
+ const [ref, setRef] = createSignal<HTMLElement | null>(null);
1478
+ const setItemRef = (element: HTMLElement) => {
1479
+ setRef(element);
1480
+ assignRef(local.ref, element);
1481
+ };
346
1482
 
347
- // Find the item node
348
1483
  const itemNode = createMemo(() => {
349
1484
  const node = state.collection.getItem(local.id);
350
1485
  if (!node) {
351
- // Create a simple node for the item
352
1486
  return {
353
- type: 'item' as const,
1487
+ type: "item" as const,
354
1488
  key: local.id,
355
1489
  value: local.item?.value ?? null,
356
1490
  textValue: local.textValue ?? String(local.id),
357
1491
  level: 0,
358
1492
  index: 0,
359
- hasChildNodes: false,
1493
+ hasChildNodes: !!local.hasChildItems,
360
1494
  childNodes: [],
361
- isExpandable: false,
1495
+ isDisabled: local.isDisabled,
1496
+ isExpandable: !!local.hasChildItems,
362
1497
  isExpanded: false,
363
1498
  } as TreeNode<T>;
364
1499
  }
365
1500
  return node;
366
1501
  });
367
1502
 
368
- // 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>>(
1503
+ const treeItemAria = createTreeItem<T, TreeCollection<T>>(
380
1504
  () => ({
381
1505
  node: itemNode(),
1506
+ selectionBehavior: state.selectionBehavior,
382
1507
  onAction: local.onAction,
1508
+ isDisabled: local.isDisabled,
1509
+ textValue: local.textValue,
383
1510
  }),
384
1511
  () => state,
385
- ref
1512
+ ref,
386
1513
  );
1514
+ const isSelected = () => treeItemAria.isSelected;
1515
+ const isDisabled = () => treeItemAria.isDisabled;
1516
+ const isPressed = () => treeItemAria.isPressed;
1517
+ const isExpanded = () => treeItemAria.isExpanded;
1518
+ const isExpandable = () => treeItemAria.isExpandable;
1519
+ const level = () => treeItemAria.level;
387
1520
 
388
- // Create hover
389
1521
  const { isHovered, hoverProps } = createHover({
390
1522
  get isDisabled() {
391
- return isDisabled;
1523
+ return isDisabled();
392
1524
  },
393
1525
  });
394
1526
 
395
- // Create focus ring
396
1527
  const { isFocusVisible, focusProps } = createFocusRing();
397
1528
 
398
- // Check if focused
399
1529
  const isFocused = createMemo(() => state.focusedKey === local.id);
1530
+ const draggableItem = createMemo(() => {
1531
+ if (!treeContext?.dragAndDropHooks?.useDraggableItem || !treeContext.dragState)
1532
+ return undefined;
1533
+ return treeContext.dragAndDropHooks.useDraggableItem(
1534
+ {
1535
+ key: local.id as string | number,
1536
+ },
1537
+ treeContext.dragState as Parameters<NonNullable<DragAndDropHooks<T>["useDraggableItem"]>>[1],
1538
+ );
1539
+ });
1540
+ const droppableItem = createMemo(() => {
1541
+ if (!treeContext?.dragAndDropHooks?.useDroppableItem || !treeContext.dropState)
1542
+ return undefined;
1543
+ return treeContext.dragAndDropHooks.useDroppableItem(
1544
+ {
1545
+ key: local.id as string | number,
1546
+ },
1547
+ treeContext.dropState as Parameters<NonNullable<DragAndDropHooks<T>["useDroppableItem"]>>[1],
1548
+ () => ref(),
1549
+ );
1550
+ });
400
1551
 
401
- // Render props values
402
1552
  const renderValues = createMemo<TreeItemRenderProps>(() => ({
403
- isSelected,
1553
+ isSelected: isSelected(),
404
1554
  isFocused: isFocused(),
405
1555
  isFocusVisible: isFocusVisible() && isFocused(),
406
- isPressed,
1556
+ isPressed: isPressed(),
407
1557
  isHovered: isHovered(),
408
- isDisabled,
409
- isExpanded,
410
- isExpandable,
411
- level,
1558
+ isDisabled: isDisabled(),
1559
+ isExpanded: isExpanded(),
1560
+ isExpandable: isExpandable(),
1561
+ level: level(),
1562
+ selectionMode: state.selectionMode,
1563
+ selectionBehavior: state.selectionBehavior,
412
1564
  }));
413
1565
 
414
- // Resolve render props
415
1566
  const renderProps = useRenderProps(
416
1567
  {
417
1568
  children: props.children,
418
1569
  class: local.class,
419
1570
  style: local.style,
420
- defaultClassName: 'solidaria-Tree-item',
1571
+ defaultClassName: "solidaria-Tree-item",
421
1572
  },
422
- renderValues
1573
+ renderValues,
423
1574
  );
424
1575
 
425
- // Remove ref from spread props
426
1576
  const cleanRowProps = () => {
427
- const { ref: _ref1, ...rest } = rowProps as Record<string, unknown>;
1577
+ const { ref: _ref1, ...rest } = treeItemAria.rowProps as Record<string, unknown>;
428
1578
  return rest;
429
1579
  };
430
1580
  const cleanHoverProps = () => {
@@ -436,36 +1586,90 @@ export function TreeItem<T extends object>(props: TreeItemProps<T>): JSX.Element
436
1586
  return rest;
437
1587
  };
438
1588
 
439
- // Item context for nested components
440
1589
  const itemContextValue = createMemo<TreeItemContextValue<T>>(() => ({
441
1590
  node: itemNode(),
442
- isExpanded,
443
- isExpandable,
444
- level,
1591
+ isExpanded: isExpanded(),
1592
+ isExpandable: isExpandable(),
1593
+ level: level(),
445
1594
  }));
446
1595
 
1596
+ const rowStyle = () => ({
1597
+ "--tree-item-level": String(level()),
1598
+ ...((typeof renderProps.style() === "object" ? renderProps.style() : {}) as Record<
1599
+ string,
1600
+ string
1601
+ >),
1602
+ });
1603
+
1604
+ const rowContent = () => (
1605
+ <TreeItemContentContext.Provider value={renderValues()}>
1606
+ <div {...treeItemAria.gridCellProps} class="solidaria-Tree-item-content">
1607
+ {renderProps.renderChildren()}
1608
+ </div>
1609
+ </TreeItemContentContext.Provider>
1610
+ );
1611
+
1612
+ const mergedRowProps = () =>
1613
+ mergeProps(
1614
+ cleanRowProps(),
1615
+ cleanHoverProps(),
1616
+ cleanFocusProps(),
1617
+ (draggableItem()?.dragProps as Record<string, unknown> | undefined) ?? {},
1618
+ (droppableItem()?.dropProps as Record<string, unknown> | undefined) ?? {},
1619
+ );
1620
+
1621
+ const onLinkedRowClick = (event: MouseEvent) => {
1622
+ const onClick = (mergedRowProps() as { onClick?: (event: MouseEvent) => void }).onClick;
1623
+ onClick?.(event);
1624
+ handleLinkClick(event, router, local.href, local.routerOptions);
1625
+ };
1626
+
1627
+ const downloadAttr = () => {
1628
+ const download = linkProps().download;
1629
+ return typeof download === "boolean" ? (download ? "" : undefined) : download;
1630
+ };
1631
+
1632
+ const referrerPolicyAttr = () => linkProps().referrerPolicy || undefined;
1633
+ const linkedRowDomProps = () =>
1634
+ local.href
1635
+ ? {
1636
+ onClick: onLinkedRowClick,
1637
+ "data-href": linkProps().href,
1638
+ "data-target": linkProps().target,
1639
+ "data-download": downloadAttr(),
1640
+ "data-rel": linkProps().rel,
1641
+ "data-hreflang": local.hrefLang,
1642
+ "data-ping": linkProps().ping,
1643
+ "data-referrer-policy": referrerPolicyAttr(),
1644
+ }
1645
+ : {};
1646
+
447
1647
  return (
448
- <TreeItemContext.Provider value={itemContextValue() as TreeItemContextValue<object>}>
1648
+ <TreeItemContext.Provider value={itemContextValue() as unknown as TreeItemContextValue<object>}>
449
1649
  <div
450
- ref={setRef}
451
- {...cleanRowProps()}
452
- {...cleanHoverProps()}
453
- {...cleanFocusProps()}
1650
+ ref={setItemRef}
1651
+ {...domProps}
1652
+ {...mergedRowProps()}
1653
+ {...linkedRowDomProps()}
454
1654
  class={renderProps.class()}
455
- style={renderProps.style()}
456
- data-selected={isSelected || undefined}
1655
+ style={rowStyle()}
1656
+ data-selected={isSelected() || undefined}
457
1657
  data-focused={isFocused() || undefined}
458
1658
  data-focus-visible={(isFocusVisible() && isFocused()) || undefined}
459
- data-pressed={isPressed || undefined}
1659
+ data-pressed={isPressed() || undefined}
460
1660
  data-hovered={isHovered() || undefined}
461
- data-disabled={isDisabled || undefined}
462
- data-expanded={isExpanded || undefined}
463
- data-expandable={isExpandable || undefined}
464
- data-level={level}
1661
+ data-disabled={isDisabled() || undefined}
1662
+ data-expanded={isExpanded() || undefined}
1663
+ data-expandable={isExpandable() || undefined}
1664
+ data-has-child-items={isExpandable() || undefined}
1665
+ data-level={level()}
1666
+ data-selection-mode={
1667
+ treeContext?.state.selectionMode !== "none" ? treeContext?.state.selectionMode : undefined
1668
+ }
1669
+ data-dragging={draggableItem()?.isDragging || undefined}
1670
+ data-drop-target={droppableItem()?.isDropTarget || undefined}
465
1671
  >
466
- <div {...gridCellProps} class="solidaria-Tree-item-content">
467
- {renderProps.renderChildren()}
468
- </div>
1672
+ {rowContent()}
469
1673
  </div>
470
1674
  </TreeItemContext.Provider>
471
1675
  );
@@ -475,38 +1679,42 @@ export function TreeItem<T extends object>(props: TreeItemProps<T>): JSX.Element
475
1679
  * A button to expand/collapse a tree item.
476
1680
  */
477
1681
  export function TreeExpandButton(props: TreeExpandButtonProps): JSX.Element {
478
- // Get item context
479
1682
  const itemContext = useContext(TreeItemContext);
480
1683
  if (!itemContext) {
481
- throw new Error('TreeExpandButton must be used within a Tree');
1684
+ throw new Error("TreeExpandButton must be used within a Tree");
482
1685
  }
483
1686
 
484
- // Get state context
485
1687
  const stateContext = useContext(TreeStateContext);
486
1688
  if (!stateContext) {
487
- throw new Error('TreeExpandButton must be used within a Tree');
1689
+ throw new Error("TreeExpandButton must be used within a Tree");
488
1690
  }
489
1691
 
490
1692
  const state = stateContext as TreeState<object, TreeCollection<object>>;
491
1693
 
492
- // Create expand button props
493
- const { expandButtonProps } = createTreeItem(
1694
+ const treeItemAria = createTreeItem(
494
1695
  () => ({ node: itemContext.node }),
495
1696
  () => state,
496
- () => null
1697
+ () => null,
497
1698
  );
498
1699
 
499
- // Remove ref and add custom handling
500
1700
  const cleanExpandProps = () => {
501
- const { ref: _ref, ...rest } = expandButtonProps as Record<string, unknown>;
1701
+ const { ref: _ref, ...rest } = treeItemAria.expandButtonProps as Record<string, unknown>;
502
1702
  return rest;
503
1703
  };
1704
+ const dataProps = () => {
1705
+ const result: Record<string, string | undefined> = {};
1706
+ for (const key in props) {
1707
+ if (key.startsWith("data-")) {
1708
+ result[key] = props[key as `data-${string}`];
1709
+ }
1710
+ }
1711
+ return result;
1712
+ };
504
1713
 
505
1714
  const isExpanded = createMemo(() => state.isExpanded(itemContext.node.key));
506
1715
 
507
- // Render children
508
1716
  const renderChildren = () => {
509
- if (typeof props.children === 'function') {
1717
+ if (typeof props.children === "function") {
510
1718
  return props.children({ isExpanded: isExpanded() });
511
1719
  }
512
1720
  return props.children;
@@ -516,7 +1724,8 @@ export function TreeExpandButton(props: TreeExpandButtonProps): JSX.Element {
516
1724
  <Show when={itemContext.isExpandable}>
517
1725
  <button
518
1726
  {...cleanExpandProps()}
519
- class={props.class ?? 'solidaria-Tree-expand-button'}
1727
+ {...dataProps()}
1728
+ class={props.class ?? "solidaria-Tree-expand-button"}
520
1729
  style={props.style}
521
1730
  data-expanded={isExpanded() || undefined}
522
1731
  >
@@ -529,23 +1738,153 @@ export function TreeExpandButton(props: TreeExpandButtonProps): JSX.Element {
529
1738
  /**
530
1739
  * A checkbox for item selection in a tree.
531
1740
  */
532
- export function TreeSelectionCheckbox(props: { itemKey: Key }): JSX.Element {
1741
+ export function TreeSelectionCheckbox(props: {
1742
+ itemKey: Key;
1743
+ class?: string;
1744
+ style?: JSX.CSSProperties;
1745
+ excludeFromTabOrder?: boolean;
1746
+ "aria-label"?: string;
1747
+ }): JSX.Element {
533
1748
  const context = useContext(TreeStateContext);
534
1749
  if (!context) {
535
- throw new Error('TreeSelectionCheckbox must be used within a Tree');
1750
+ throw new Error("TreeSelectionCheckbox must be used within a Tree");
536
1751
  }
537
1752
 
538
1753
  const state = context as TreeState<object, TreeCollection<object>>;
539
1754
 
540
- const { checkboxProps } = createTreeSelectionCheckbox<object, TreeCollection<object>>(
1755
+ const treeSelectionCheckboxAria = createTreeSelectionCheckbox<object, TreeCollection<object>>(
541
1756
  () => ({ key: props.itemKey }),
542
- () => state
1757
+ () => state,
543
1758
  );
544
1759
 
545
- return <input {...checkboxProps} class="solidaria-Tree-checkbox" />;
1760
+ return (
1761
+ <input
1762
+ {...treeSelectionCheckboxAria.checkboxProps}
1763
+ class={props.class ?? "solidaria-Tree-checkbox"}
1764
+ style={props.style}
1765
+ tabIndex={props.excludeFromTabOrder ? -1 : undefined}
1766
+ aria-label={props["aria-label"] ?? treeSelectionCheckboxAria.checkboxProps["aria-label"]}
1767
+ />
1768
+ );
1769
+ }
1770
+
1771
+ export function TreeLoadMoreItem(props: TreeLoadMoreItemProps): JSX.Element {
1772
+ let sentinelRef: HTMLDivElement | undefined;
1773
+ const [isPending, setIsPending] = createSignal(false);
1774
+ const isLoading = () =>
1775
+ !!props.isLoading ||
1776
+ props.loadingState === "loading" ||
1777
+ props.loadingState === "loadingMore" ||
1778
+ isPending();
1779
+
1780
+ const triggerLoadMore = async () => {
1781
+ if (isLoading()) return;
1782
+ setIsPending(true);
1783
+ try {
1784
+ await props.onLoadMore();
1785
+ } finally {
1786
+ setIsPending(false);
1787
+ }
1788
+ };
1789
+
1790
+ createEffect(() => {
1791
+ if (!sentinelRef || typeof IntersectionObserver !== "function") return;
1792
+ const offset = props.scrollOffset ?? 1;
1793
+ const margin = `0px 0px ${100 * offset}% 0px`;
1794
+ const observer = new IntersectionObserver(
1795
+ (entries) => {
1796
+ if (entries[0]?.isIntersecting) {
1797
+ void triggerLoadMore();
1798
+ }
1799
+ },
1800
+ { rootMargin: margin },
1801
+ );
1802
+ observer.observe(sentinelRef);
1803
+ return () => observer.disconnect();
1804
+ });
1805
+
1806
+ const renderProps = useRenderProps(
1807
+ {
1808
+ children: props.children ?? (() => (isLoading() ? "Loading more..." : "Load more")),
1809
+ class: props.class,
1810
+ style: props.style,
1811
+ defaultClassName: "solidaria-Tree-loadMore",
1812
+ },
1813
+ () => ({ isLoading: isLoading() }),
1814
+ );
1815
+
1816
+ return (
1817
+ <>
1818
+ <div style={{ position: "relative", width: 0, height: 0, overflow: "hidden" }} inert>
1819
+ <div ref={sentinelRef} style={{ position: "absolute", height: "1px", width: "1px" }} />
1820
+ </div>
1821
+ <div
1822
+ role="row"
1823
+ aria-level={props.level ?? 1}
1824
+ onFocus={() => {
1825
+ void triggerLoadMore();
1826
+ }}
1827
+ onFocusIn={() => {
1828
+ void triggerLoadMore();
1829
+ }}
1830
+ class={renderProps.class()}
1831
+ style={renderProps.style()}
1832
+ data-loading={isLoading() || undefined}
1833
+ data-level={props.level ?? 1}
1834
+ >
1835
+ <div
1836
+ role="gridcell"
1837
+ onFocus={() => {
1838
+ void triggerLoadMore();
1839
+ }}
1840
+ >
1841
+ {renderProps.renderChildren()}
1842
+ </div>
1843
+ </div>
1844
+ </>
1845
+ );
1846
+ }
1847
+
1848
+ export interface TreeItemContentProps {
1849
+ children?: RenderChildren<TreeItemContentRenderProps>;
1850
+ }
1851
+ export type TreeItemContentRenderProps = TreeItemRenderProps;
1852
+
1853
+ export function TreeItemContent(props: TreeItemContentProps): JSX.Element {
1854
+ const context = useContext(TreeItemContentContext);
1855
+ if (!context) {
1856
+ throw new Error("TreeItemContent must be used within a TreeItem");
1857
+ }
1858
+
1859
+ const renderProps = useRenderProps(
1860
+ {
1861
+ children: props.children,
1862
+ },
1863
+ () => context,
1864
+ );
1865
+
1866
+ return <>{renderProps.renderChildren()}</>;
1867
+ }
1868
+
1869
+ export function TreeSection(props: TreeSectionProps): JSX.Element {
1870
+ return <Section {...props} />;
1871
+ }
1872
+
1873
+ export function TreeHeader(props: TreeHeaderProps): JSX.Element {
1874
+ return <Header {...props} />;
546
1875
  }
547
1876
 
548
- // Attach static properties
549
1877
  Tree.Item = TreeItem;
550
1878
  Tree.ExpandButton = TreeExpandButton;
551
1879
  Tree.SelectionCheckbox = TreeSelectionCheckbox;
1880
+ Tree.LoadMoreItem = TreeLoadMoreItem;
1881
+ Tree.Section = TreeSection;
1882
+ Tree.Header = TreeHeader;
1883
+
1884
+ function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
1885
+ if (a.size !== b.size) return false;
1886
+ for (const entry of a) {
1887
+ if (!b.has(entry)) return false;
1888
+ }
1889
+ return true;
1890
+ }