@astryxdesign/core 0.1.7 → 0.1.8-canary.c5fa19b

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 (190) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +30 -19
  3. package/dist/Avatar/Avatar.d.ts +10 -5
  4. package/dist/Avatar/Avatar.d.ts.map +1 -1
  5. package/dist/Avatar/Avatar.js +12 -8
  6. package/dist/Avatar/AvatarSizeContext.d.ts +1 -1
  7. package/dist/Avatar/AvatarSizeContext.js +1 -1
  8. package/dist/Avatar/AvatarStatusDot.d.ts +2 -2
  9. package/dist/Avatar/AvatarStatusDot.js +2 -2
  10. package/dist/AvatarGroup/AvatarGroup.d.ts +2 -2
  11. package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
  12. package/dist/AvatarGroup/AvatarGroup.js +2 -2
  13. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
  14. package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
  15. package/dist/AvatarGroup/AvatarGroupOverflow.js +16 -14
  16. package/dist/Carousel/Carousel.d.ts +3 -2
  17. package/dist/Carousel/Carousel.d.ts.map +1 -1
  18. package/dist/Carousel/Carousel.js +32 -2
  19. package/dist/Chat/ChatMessage.d.ts +1 -1
  20. package/dist/Chat/ChatMessage.d.ts.map +1 -1
  21. package/dist/Chat/ChatMessage.js +1 -1
  22. package/dist/Citation/Citation.d.ts.map +1 -1
  23. package/dist/Citation/Citation.js +10 -4
  24. package/dist/Collapsible/Collapsible.d.ts +11 -1
  25. package/dist/Collapsible/Collapsible.d.ts.map +1 -1
  26. package/dist/Collapsible/Collapsible.js +36 -4
  27. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  28. package/dist/ContextMenu/ContextMenu.js +4 -3
  29. package/dist/ContextMenu/index.d.ts +3 -0
  30. package/dist/ContextMenu/index.d.ts.map +1 -1
  31. package/dist/ContextMenu/index.js +7 -1
  32. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  33. package/dist/DropdownMenu/DropdownMenu.js +8 -4
  34. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +89 -0
  35. package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -0
  36. package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +170 -0
  37. package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -1
  38. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenuContext.js +18 -0
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +69 -0
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -0
  42. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +70 -0
  43. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +69 -0
  44. package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +1 -0
  45. package/dist/DropdownMenu/DropdownMenuRadioItem.js +178 -0
  46. package/dist/DropdownMenu/index.d.ts +5 -1
  47. package/dist/DropdownMenu/index.d.ts.map +1 -1
  48. package/dist/DropdownMenu/index.js +10 -1
  49. package/dist/DropdownMenu/menuItemRoles.d.ts +13 -0
  50. package/dist/DropdownMenu/menuItemRoles.d.ts.map +1 -0
  51. package/dist/DropdownMenu/menuItemRoles.js +15 -0
  52. package/dist/Icon/Icon.d.ts +43 -13
  53. package/dist/Icon/Icon.d.ts.map +1 -1
  54. package/dist/Icon/Icon.js +62 -27
  55. package/dist/Outline/Outline.d.ts +69 -3
  56. package/dist/Outline/Outline.d.ts.map +1 -1
  57. package/dist/Outline/Outline.js +129 -26
  58. package/dist/Outline/useScrollSpy.d.ts +27 -7
  59. package/dist/Outline/useScrollSpy.d.ts.map +1 -1
  60. package/dist/Outline/useScrollSpy.js +202 -70
  61. package/dist/TabList/Tab.d.ts.map +1 -1
  62. package/dist/TabList/Tab.js +2 -2
  63. package/dist/TabList/TabList.d.ts.map +1 -1
  64. package/dist/TabList/TabList.js +2 -0
  65. package/dist/TabList/TabMenu.d.ts.map +1 -1
  66. package/dist/TabList/TabMenu.js +1 -1
  67. package/dist/Table/Table.d.ts +1 -1
  68. package/dist/Table/Table.js +1 -1
  69. package/dist/Table/index.d.ts +2 -0
  70. package/dist/Table/index.d.ts.map +1 -1
  71. package/dist/Table/index.js +1 -0
  72. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  73. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +24 -2
  74. package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
  75. package/dist/Table/plugins/sortable/useTableSortable.js +18 -7
  76. package/dist/Table/plugins/tree/index.d.ts +5 -0
  77. package/dist/Table/plugins/tree/index.d.ts.map +1 -0
  78. package/dist/Table/plugins/tree/index.js +4 -0
  79. package/dist/Table/plugins/tree/useTableTreeData.d.ts +38 -0
  80. package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +1 -0
  81. package/dist/Table/plugins/tree/useTableTreeData.js +358 -0
  82. package/dist/Table/plugins/tree/useTableTreeState.d.ts +51 -0
  83. package/dist/Table/plugins/tree/useTableTreeState.d.ts.map +1 -0
  84. package/dist/Table/plugins/tree/useTableTreeState.js +175 -0
  85. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  86. package/dist/Table/useBaseTablePlugins.js +6 -3
  87. package/dist/Toolbar/Toolbar.d.ts.map +1 -1
  88. package/dist/Toolbar/Toolbar.js +8 -1
  89. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  90. package/dist/TreeList/TreeListItem.js +5 -14
  91. package/dist/astryx.css +24 -4
  92. package/dist/astryx.umd.js +54 -54
  93. package/dist/astryx.umd.js.map +4 -4
  94. package/dist/theme/Theme.js +1 -1
  95. package/dist/theme/defineTheme.d.ts +1 -1
  96. package/dist/theme/defineTheme.js +1 -1
  97. package/dist/theme/hct.d.ts +0 -1
  98. package/dist/theme/hct.d.ts.map +1 -1
  99. package/dist/theme/hct.js +0 -3
  100. package/docs.mjs +32 -461
  101. package/locales/en.json +200 -172
  102. package/locales/pseudo.json +21 -0
  103. package/package.json +3 -1
  104. package/scripts/postinstall.mjs +93 -0
  105. package/src/Avatar/Avatar.doc.mjs +6 -6
  106. package/src/Avatar/Avatar.tsx +16 -11
  107. package/src/Avatar/AvatarSizeContext.ts +1 -1
  108. package/src/Avatar/AvatarStatusDot.tsx +2 -2
  109. package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
  110. package/src/AvatarGroup/AvatarGroup.test.tsx +17 -3
  111. package/src/AvatarGroup/AvatarGroup.tsx +3 -3
  112. package/src/AvatarGroup/AvatarGroupOverflow.doc.mjs +4 -4
  113. package/src/AvatarGroup/AvatarGroupOverflow.tsx +21 -5
  114. package/src/Carousel/Carousel.doc.mjs +1 -0
  115. package/src/Carousel/Carousel.test.tsx +96 -1
  116. package/src/Carousel/Carousel.tsx +29 -2
  117. package/src/Chat/Chat.doc.mjs +1 -1
  118. package/src/Chat/ChatMessage.tsx +4 -2
  119. package/src/CheckboxInput/CheckboxInput.doc.mjs +1 -1
  120. package/src/Citation/Citation.test.tsx +51 -0
  121. package/src/Citation/Citation.tsx +8 -2
  122. package/src/Collapsible/Collapsible.doc.mjs +7 -0
  123. package/src/Collapsible/Collapsible.test.tsx +80 -0
  124. package/src/Collapsible/Collapsible.tsx +58 -5
  125. package/src/ContextMenu/ContextMenu.test.tsx +75 -1
  126. package/src/ContextMenu/ContextMenu.tsx +10 -5
  127. package/src/ContextMenu/index.ts +15 -0
  128. package/src/DropdownMenu/DropdownMenu.doc.mjs +23 -0
  129. package/src/DropdownMenu/DropdownMenu.tsx +11 -6
  130. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +95 -0
  131. package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +237 -0
  132. package/src/DropdownMenu/DropdownMenuContext.tsx +29 -1
  133. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +7 -1
  134. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +80 -0
  135. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +119 -0
  136. package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +80 -0
  137. package/src/DropdownMenu/DropdownMenuRadioItem.tsx +232 -0
  138. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +163 -0
  139. package/src/DropdownMenu/index.ts +23 -3
  140. package/src/DropdownMenu/menuItemRoles.ts +22 -0
  141. package/src/Field/Field.doc.mjs +1 -1
  142. package/src/Field/FieldLabel.doc.mjs +1 -1
  143. package/src/Icon/Icon.doc.mjs +12 -0
  144. package/src/Icon/Icon.test.tsx +142 -0
  145. package/src/Icon/Icon.tsx +98 -30
  146. package/src/Item/Item.doc.mjs +1 -1
  147. package/src/NumberInput/NumberInput.doc.mjs +2 -2
  148. package/src/Outline/Outline.doc.mjs +123 -3
  149. package/src/Outline/Outline.test.tsx +735 -0
  150. package/src/Outline/Outline.tsx +184 -33
  151. package/src/Outline/useScrollSpy.ts +248 -72
  152. package/src/Selector/SelectorOption.doc.mjs +1 -1
  153. package/src/SideNav/SideNavItem.doc.mjs +2 -2
  154. package/src/Switch/Switch.doc.mjs +1 -1
  155. package/src/TabList/Tab.tsx +5 -1
  156. package/src/TabList/TabList.doc.mjs +3 -3
  157. package/src/TabList/TabList.test.tsx +66 -0
  158. package/src/TabList/TabList.tsx +8 -0
  159. package/src/TabList/TabMenu.tsx +4 -1
  160. package/src/Table/Table.doc.mjs +2 -0
  161. package/src/Table/Table.tsx +1 -1
  162. package/src/Table/index.ts +7 -0
  163. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +90 -1
  164. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +37 -5
  165. package/src/Table/plugins/sortable/useTableSortable.test.tsx +95 -9
  166. package/src/Table/plugins/sortable/useTableSortable.tsx +23 -7
  167. package/src/Table/plugins/tree/index.ts +12 -0
  168. package/src/Table/plugins/tree/useTableTreeData-perf.test.tsx +163 -0
  169. package/src/Table/plugins/tree/useTableTreeData.test.tsx +594 -0
  170. package/src/Table/plugins/tree/useTableTreeData.tsx +495 -0
  171. package/src/Table/plugins/tree/useTableTreeState.test.tsx +636 -0
  172. package/src/Table/plugins/tree/useTableTreeState.tsx +253 -0
  173. package/src/Table/useBaseTablePlugins.ts +6 -2
  174. package/src/Table/useTableTreeData.doc.mjs +75 -0
  175. package/src/Table/useTableTreeState.doc.mjs +98 -0
  176. package/src/TextArea/TextArea.doc.mjs +1 -1
  177. package/src/TextInput/TextInput.doc.mjs +1 -1
  178. package/src/Toolbar/Toolbar.tsx +10 -3
  179. package/src/TreeList/TreeList.test.tsx +32 -1
  180. package/src/TreeList/TreeListItem.tsx +19 -10
  181. package/src/docs-script.test.mjs +18 -13
  182. package/src/postinstall.test.mjs +71 -0
  183. package/src/theme/Theme.doc.mjs +4 -4
  184. package/src/theme/Theme.tsx +1 -1
  185. package/src/theme/defineTheme.ts +1 -1
  186. package/src/theme/hct.ts +0 -4
  187. package/dist/TreeList/treeListItem.markers.stylex.d.ts +0 -16
  188. package/dist/TreeList/treeListItem.markers.stylex.d.ts.map +0 -1
  189. package/dist/TreeList/treeListItem.markers.stylex.js +0 -22
  190. package/src/TreeList/treeListItem.markers.stylex.ts +0 -20
@@ -0,0 +1,636 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useTableTreeState.test.tsx
5
+ * @input Uses vitest, @testing-library/react
6
+ * @output Unit tests for useTableTreeState hook
7
+ * @position Testing; validates tree expansion state + nested-data flattening
8
+ */
9
+
10
+ import {describe, it, expect, vi} from 'vitest';
11
+ import {act, renderHook} from '@testing-library/react';
12
+ import {useTableTreeState} from './useTableTreeState';
13
+
14
+ // =============================================================================
15
+ // Test Data
16
+ // =============================================================================
17
+
18
+ interface FileRow extends Record<string, unknown> {
19
+ id: string;
20
+ name: string;
21
+ size: number;
22
+ children?: FileRow[];
23
+ }
24
+
25
+ /**
26
+ * src/
27
+ * components/
28
+ * Button.tsx
29
+ * Input.tsx
30
+ * utils.ts
31
+ * README.md
32
+ */
33
+ const fileTree: FileRow[] = [
34
+ {
35
+ id: 'src',
36
+ name: 'src',
37
+ size: 0,
38
+ children: [
39
+ {
40
+ id: 'components',
41
+ name: 'components',
42
+ size: 0,
43
+ children: [
44
+ {id: 'button', name: 'Button.tsx', size: 120},
45
+ {id: 'input', name: 'Input.tsx', size: 80},
46
+ ],
47
+ },
48
+ {id: 'utils', name: 'utils.ts', size: 40},
49
+ ],
50
+ },
51
+ {id: 'readme', name: 'README.md', size: 10},
52
+ ];
53
+
54
+ const ids = (rows: FileRow[]) => rows.map(r => r.id);
55
+
56
+ // =============================================================================
57
+ // Flattening
58
+ // =============================================================================
59
+
60
+ describe('useTableTreeState — flattening', () => {
61
+ it('emits only root rows when nothing is expanded', () => {
62
+ const {result} = renderHook(() =>
63
+ useTableTreeState({data: fileTree, idKey: 'id'}),
64
+ );
65
+
66
+ expect(ids(result.current.visibleData)).toEqual(['src', 'readme']);
67
+ });
68
+
69
+ it('reveals children of defaultExpandedIds in depth-first order', () => {
70
+ const {result} = renderHook(() =>
71
+ useTableTreeState({
72
+ data: fileTree,
73
+ idKey: 'id',
74
+ defaultExpandedIds: ['src', 'components'],
75
+ }),
76
+ );
77
+
78
+ expect(ids(result.current.visibleData)).toEqual([
79
+ 'src',
80
+ 'components',
81
+ 'button',
82
+ 'input',
83
+ 'utils',
84
+ 'readme',
85
+ ]);
86
+ });
87
+
88
+ it('keeps a collapsed subtree unmounted even when its descendants are in the expanded set', () => {
89
+ // 'components' is expanded but its parent 'src' is not — nothing below
90
+ // 'src' is visible.
91
+ const {result} = renderHook(() =>
92
+ useTableTreeState({
93
+ data: fileTree,
94
+ idKey: 'id',
95
+ defaultExpandedIds: ['components'],
96
+ }),
97
+ );
98
+
99
+ expect(ids(result.current.visibleData)).toEqual(['src', 'readme']);
100
+ });
101
+ });
102
+
103
+ // =============================================================================
104
+ // Toggling (uncontrolled)
105
+ // =============================================================================
106
+
107
+ describe('useTableTreeState — uncontrolled toggling', () => {
108
+ it('expands a row via treeConfig.onToggleItem', () => {
109
+ const {result} = renderHook(() =>
110
+ useTableTreeState({data: fileTree, idKey: 'id'}),
111
+ );
112
+
113
+ act(() => {
114
+ result.current.treeConfig.onToggleItem(fileTree[0]);
115
+ });
116
+
117
+ expect(ids(result.current.visibleData)).toEqual([
118
+ 'src',
119
+ 'components',
120
+ 'utils',
121
+ 'readme',
122
+ ]);
123
+ });
124
+
125
+ it('collapses an expanded row via treeConfig.onToggleItem', () => {
126
+ const {result} = renderHook(() =>
127
+ useTableTreeState({
128
+ data: fileTree,
129
+ idKey: 'id',
130
+ defaultExpandedIds: ['src'],
131
+ }),
132
+ );
133
+
134
+ act(() => {
135
+ result.current.treeConfig.onToggleItem(fileTree[0]);
136
+ });
137
+
138
+ expect(ids(result.current.visibleData)).toEqual(['src', 'readme']);
139
+ });
140
+ });
141
+
142
+ // =============================================================================
143
+ // Controlled mode
144
+ // =============================================================================
145
+
146
+ describe('useTableTreeState — controlled mode', () => {
147
+ it('derives visibility from the controlled expandedIds set', () => {
148
+ const {result, rerender} = renderHook(
149
+ ({expanded}: {expanded: ReadonlySet<string>}) =>
150
+ useTableTreeState({
151
+ data: fileTree,
152
+ idKey: 'id',
153
+ expandedIds: expanded,
154
+ onExpandedIdsChange: () => {},
155
+ }),
156
+ {initialProps: {expanded: new Set<string>()}},
157
+ );
158
+
159
+ expect(ids(result.current.visibleData)).toEqual(['src', 'readme']);
160
+
161
+ rerender({expanded: new Set(['src'])});
162
+ expect(ids(result.current.visibleData)).toEqual([
163
+ 'src',
164
+ 'components',
165
+ 'utils',
166
+ 'readme',
167
+ ]);
168
+ });
169
+
170
+ it('reports toggles through onExpandedIdsChange without mutating its own state', () => {
171
+ const onExpandedIdsChange = vi.fn();
172
+ const {result} = renderHook(() =>
173
+ useTableTreeState({
174
+ data: fileTree,
175
+ idKey: 'id',
176
+ expandedIds: new Set<string>(),
177
+ onExpandedIdsChange,
178
+ }),
179
+ );
180
+
181
+ act(() => {
182
+ result.current.treeConfig.onToggleItem(fileTree[0]);
183
+ });
184
+
185
+ expect(onExpandedIdsChange).toHaveBeenCalledWith(new Set(['src']));
186
+ // Controlled: the visible rows only change when the owner passes a new set.
187
+ expect(ids(result.current.visibleData)).toEqual(['src', 'readme']);
188
+ });
189
+
190
+ it('fires onExpandedIdsChange in uncontrolled mode too', () => {
191
+ const onExpandedIdsChange = vi.fn();
192
+ const {result} = renderHook(() =>
193
+ useTableTreeState({
194
+ data: fileTree,
195
+ idKey: 'id',
196
+ onExpandedIdsChange,
197
+ }),
198
+ );
199
+
200
+ act(() => {
201
+ result.current.treeConfig.onToggleItem(fileTree[0]);
202
+ });
203
+
204
+ expect(onExpandedIdsChange).toHaveBeenCalledWith(new Set(['src']));
205
+ expect(ids(result.current.visibleData)).toEqual([
206
+ 'src',
207
+ 'components',
208
+ 'utils',
209
+ 'readme',
210
+ ]);
211
+ });
212
+ });
213
+
214
+ // =============================================================================
215
+ // expandAll / collapseAll
216
+ // =============================================================================
217
+
218
+ describe('useTableTreeState — expandAll / collapseAll', () => {
219
+ it('expandAll reveals every level of the tree', () => {
220
+ const {result} = renderHook(() =>
221
+ useTableTreeState({data: fileTree, idKey: 'id'}),
222
+ );
223
+
224
+ act(() => {
225
+ result.current.expandAll();
226
+ });
227
+
228
+ expect(ids(result.current.visibleData)).toEqual([
229
+ 'src',
230
+ 'components',
231
+ 'button',
232
+ 'input',
233
+ 'utils',
234
+ 'readme',
235
+ ]);
236
+ });
237
+
238
+ it('collapseAll returns to roots only', () => {
239
+ const {result} = renderHook(() =>
240
+ useTableTreeState({
241
+ data: fileTree,
242
+ idKey: 'id',
243
+ defaultExpandedIds: ['src', 'components'],
244
+ }),
245
+ );
246
+
247
+ act(() => {
248
+ result.current.collapseAll();
249
+ });
250
+
251
+ expect(ids(result.current.visibleData)).toEqual(['src', 'readme']);
252
+ });
253
+
254
+ it('exposes the current expandedIds set', () => {
255
+ const {result} = renderHook(() =>
256
+ useTableTreeState({
257
+ data: fileTree,
258
+ idKey: 'id',
259
+ defaultExpandedIds: ['src'],
260
+ }),
261
+ );
262
+
263
+ expect(result.current.expandedIds).toEqual(new Set(['src']));
264
+ });
265
+ });
266
+
267
+ // =============================================================================
268
+ // Row meta
269
+ // =============================================================================
270
+
271
+ describe('useTableTreeState — row meta', () => {
272
+ it('reports 0-based level, hasChildren, and isExpanded per row', () => {
273
+ const {result} = renderHook(() =>
274
+ useTableTreeState({
275
+ data: fileTree,
276
+ idKey: 'id',
277
+ defaultExpandedIds: ['src', 'components'],
278
+ }),
279
+ );
280
+
281
+ const meta = (id: string) => {
282
+ const item = result.current.visibleData.find(r => r.id === id);
283
+ if (!item) {
284
+ throw new Error(`row ${id} not visible`);
285
+ }
286
+ return result.current.treeConfig.getRowMeta(item);
287
+ };
288
+
289
+ expect(meta('src')).toEqual({
290
+ id: 'src',
291
+ level: 0,
292
+ hasChildren: true,
293
+ isExpanded: true,
294
+ });
295
+ expect(meta('components')).toEqual({
296
+ id: 'components',
297
+ level: 1,
298
+ hasChildren: true,
299
+ isExpanded: true,
300
+ });
301
+ expect(meta('button')).toEqual({
302
+ id: 'button',
303
+ level: 2,
304
+ hasChildren: false,
305
+ isExpanded: false,
306
+ });
307
+ expect(meta('readme')).toEqual({
308
+ id: 'readme',
309
+ level: 0,
310
+ hasChildren: false,
311
+ isExpanded: false,
312
+ });
313
+ });
314
+
315
+ it('treats an empty children array as a leaf', () => {
316
+ const data: FileRow[] = [
317
+ {id: 'a', name: 'a', size: 0, children: []},
318
+ {id: 'b', name: 'b', size: 0, children: [{id: 'c', name: 'c', size: 0}]},
319
+ ];
320
+ const {result} = renderHook(() => useTableTreeState({data, idKey: 'id'}));
321
+
322
+ expect(result.current.treeConfig.getRowMeta(data[0])?.hasChildren).toBe(
323
+ false,
324
+ );
325
+ expect(result.current.treeConfig.getRowMeta(data[1])?.hasChildren).toBe(
326
+ true,
327
+ );
328
+ });
329
+
330
+ it('flags hasExpandableRows=false for flat data (migration no-op)', () => {
331
+ const flat: FileRow[] = [
332
+ {id: 'a', name: 'a', size: 1},
333
+ {id: 'b', name: 'b', size: 2},
334
+ ];
335
+ const {result} = renderHook(() =>
336
+ useTableTreeState({data: flat, idKey: 'id'}),
337
+ );
338
+
339
+ expect(result.current.treeConfig.hasExpandableRows).toBe(false);
340
+ });
341
+
342
+ it('flags hasExpandableRows=true when any row has children', () => {
343
+ const {result} = renderHook(() =>
344
+ useTableTreeState({data: fileTree, idKey: 'id'}),
345
+ );
346
+
347
+ expect(result.current.treeConfig.hasExpandableRows).toBe(true);
348
+ });
349
+ });
350
+
351
+ // =============================================================================
352
+ // Lazy loading (isItemExpandable)
353
+ // =============================================================================
354
+
355
+ describe('useTableTreeState — isItemExpandable', () => {
356
+ it('forces an expander on rows whose children have not loaded yet', () => {
357
+ const lazy: FileRow[] = [
358
+ {id: 'folder', name: 'folder', size: 0}, // no children yet
359
+ {id: 'file', name: 'file', size: 5},
360
+ ];
361
+ const {result} = renderHook(() =>
362
+ useTableTreeState({
363
+ data: lazy,
364
+ idKey: 'id',
365
+ isItemExpandable: item => item.id === 'folder',
366
+ }),
367
+ );
368
+
369
+ expect(result.current.treeConfig.getRowMeta(lazy[0])?.hasChildren).toBe(
370
+ true,
371
+ );
372
+ expect(result.current.treeConfig.getRowMeta(lazy[1])?.hasChildren).toBe(
373
+ false,
374
+ );
375
+ expect(result.current.treeConfig.hasExpandableRows).toBe(true);
376
+ });
377
+
378
+ it('expandAll includes isItemExpandable rows without loaded children', () => {
379
+ const lazy: FileRow[] = [{id: 'folder', name: 'folder', size: 0}];
380
+ const onExpandedIdsChange = vi.fn();
381
+ const {result} = renderHook(() =>
382
+ useTableTreeState({
383
+ data: lazy,
384
+ idKey: 'id',
385
+ isItemExpandable: () => true,
386
+ onExpandedIdsChange,
387
+ }),
388
+ );
389
+
390
+ act(() => {
391
+ result.current.expandAll();
392
+ });
393
+
394
+ expect(onExpandedIdsChange).toHaveBeenCalledWith(new Set(['folder']));
395
+ });
396
+ });
397
+
398
+ // =============================================================================
399
+ // Sibling sorting
400
+ // =============================================================================
401
+
402
+ describe('useTableTreeState — sortSiblings', () => {
403
+ it('sorts within sibling groups, never across levels', () => {
404
+ const byNameDesc = (siblings: FileRow[]) =>
405
+ [...siblings].sort((a, b) => b.name.localeCompare(a.name));
406
+
407
+ const {result} = renderHook(() =>
408
+ useTableTreeState({
409
+ data: fileTree,
410
+ idKey: 'id',
411
+ defaultExpandedIds: ['src', 'components'],
412
+ sortSiblings: byNameDesc,
413
+ }),
414
+ );
415
+
416
+ // Roots sorted desc: src > README.md; src's children desc: utils > components;
417
+ // components' children desc: Input.tsx > Button.tsx. Children always stay
418
+ // directly under their parent.
419
+ expect(ids(result.current.visibleData)).toEqual([
420
+ 'src',
421
+ 'utils',
422
+ 'components',
423
+ 'input',
424
+ 'button',
425
+ 'readme',
426
+ ]);
427
+ });
428
+ });
429
+
430
+ // =============================================================================
431
+ // Accessors
432
+ // =============================================================================
433
+
434
+ describe('useTableTreeState — accessors', () => {
435
+ it('supports a custom childrenKey', () => {
436
+ interface OrgRow extends Record<string, unknown> {
437
+ id: string;
438
+ reports?: OrgRow[];
439
+ }
440
+ const org: OrgRow[] = [{id: 'ceo', reports: [{id: 'cto'}, {id: 'cfo'}]}];
441
+ const {result} = renderHook(() =>
442
+ useTableTreeState({
443
+ data: org,
444
+ idKey: 'id',
445
+ childrenKey: 'reports',
446
+ defaultExpandedIds: ['ceo'],
447
+ }),
448
+ );
449
+
450
+ expect(result.current.visibleData.map(r => r.id)).toEqual([
451
+ 'ceo',
452
+ 'cto',
453
+ 'cfo',
454
+ ]);
455
+ });
456
+
457
+ it('supports a function idKey returning numbers', () => {
458
+ interface NumRow extends Record<string, unknown> {
459
+ num: number;
460
+ children?: NumRow[];
461
+ }
462
+ const data: NumRow[] = [{num: 1, children: [{num: 2}]}];
463
+ const {result} = renderHook(() =>
464
+ useTableTreeState({
465
+ data,
466
+ idKey: item => item.num,
467
+ defaultExpandedIds: ['1'],
468
+ }),
469
+ );
470
+
471
+ expect(result.current.visibleData.map(r => r.num)).toEqual([1, 2]);
472
+ });
473
+
474
+ it('passes indent and treeColumnKey through to the tree config', () => {
475
+ const {result} = renderHook(() =>
476
+ useTableTreeState({
477
+ data: fileTree,
478
+ idKey: 'id',
479
+ indent: 'lg',
480
+ treeColumnKey: 'name',
481
+ }),
482
+ );
483
+
484
+ expect(result.current.treeConfig.indent).toBe('lg');
485
+ expect(result.current.treeConfig.treeColumnKey).toBe('name');
486
+ });
487
+ });
488
+
489
+ // =============================================================================
490
+ // Hostile / degenerate data
491
+ // =============================================================================
492
+
493
+ describe('useTableTreeState — hostile data', () => {
494
+ it('does not blow the stack on a self-referencing cycle', () => {
495
+ const a: FileRow = {id: 'a', name: 'a', size: 0};
496
+ a.children = [a]; // self-cycle
497
+ const {result} = renderHook(() =>
498
+ useTableTreeState({
499
+ data: [a],
500
+ idKey: 'id',
501
+ defaultExpandedIds: ['a'],
502
+ }),
503
+ );
504
+
505
+ // The cyclic edge is skipped: 'a' renders exactly once.
506
+ expect(ids(result.current.visibleData)).toEqual(['a']);
507
+ });
508
+
509
+ it('does not blow the stack when a descendant points back at an ancestor', () => {
510
+ const parent: FileRow = {id: 'p', name: 'p', size: 0};
511
+ const child: FileRow = {id: 'c', name: 'c', size: 0, children: [parent]};
512
+ parent.children = [child];
513
+ const {result} = renderHook(() =>
514
+ useTableTreeState({
515
+ data: [parent],
516
+ idKey: 'id',
517
+ defaultExpandedIds: ['p', 'c'],
518
+ }),
519
+ );
520
+
521
+ expect(ids(result.current.visibleData)).toEqual(['p', 'c']);
522
+ });
523
+
524
+ it('renders duplicate ids in different subtrees without crashing (shared expansion state)', () => {
525
+ const data: FileRow[] = [
526
+ {
527
+ id: 'x',
528
+ name: 'first-x',
529
+ size: 0,
530
+ children: [{id: 'dup', name: 'dup-under-x', size: 1}],
531
+ },
532
+ {
533
+ id: 'y',
534
+ name: 'second-y',
535
+ size: 0,
536
+ children: [{id: 'dup', name: 'dup-under-y', size: 2}],
537
+ },
538
+ ];
539
+ const {result} = renderHook(() =>
540
+ useTableTreeState({
541
+ data,
542
+ idKey: 'id',
543
+ defaultExpandedIds: ['x', 'y'],
544
+ }),
545
+ );
546
+
547
+ // Both duplicate rows stay visible; ids sharing a key share expansion.
548
+ expect(ids(result.current.visibleData)).toEqual(['x', 'dup', 'y', 'dup']);
549
+ });
550
+
551
+ it('ignores expanded ids that match no row', () => {
552
+ const {result} = renderHook(() =>
553
+ useTableTreeState({
554
+ data: fileTree,
555
+ idKey: 'id',
556
+ defaultExpandedIds: ['ghost', 'src'],
557
+ }),
558
+ );
559
+
560
+ expect(ids(result.current.visibleData)).toEqual([
561
+ 'src',
562
+ 'components',
563
+ 'utils',
564
+ 'readme',
565
+ ]);
566
+ });
567
+
568
+ it('handles empty data', () => {
569
+ const {result} = renderHook(() =>
570
+ useTableTreeState<FileRow>({data: [], idKey: 'id'}),
571
+ );
572
+
573
+ expect(result.current.visibleData).toEqual([]);
574
+ expect(result.current.treeConfig.hasExpandableRows).toBe(false);
575
+ act(() => {
576
+ result.current.expandAll(); // must not throw
577
+ });
578
+ expect(result.current.expandedIds).toEqual(new Set());
579
+ });
580
+
581
+ it('never mutates the consumer data when sortSiblings sorts in place', () => {
582
+ const inPlaceSorter = (siblings: FileRow[]) =>
583
+ siblings.sort((a, b) => b.name.localeCompare(a.name));
584
+
585
+ const childOrderBefore = fileTree[0].children!.map(c => c.id);
586
+ renderHook(() =>
587
+ useTableTreeState({
588
+ data: fileTree,
589
+ idKey: 'id',
590
+ defaultExpandedIds: ['src'],
591
+ sortSiblings: inPlaceSorter,
592
+ }),
593
+ );
594
+
595
+ expect(fileTree[0].children!.map(c => c.id)).toEqual(childOrderBefore);
596
+ });
597
+ });
598
+
599
+ // =============================================================================
600
+ // Semantics edge cases
601
+ // =============================================================================
602
+
603
+ describe('useTableTreeState — semantics edges', () => {
604
+ it('defaultExpandedIds is ignored when expandedIds is controlled', () => {
605
+ const {result} = renderHook(() =>
606
+ useTableTreeState({
607
+ data: fileTree,
608
+ idKey: 'id',
609
+ defaultExpandedIds: ['src'],
610
+ expandedIds: new Set<string>(),
611
+ onExpandedIdsChange: () => {},
612
+ }),
613
+ );
614
+
615
+ expect(ids(result.current.visibleData)).toEqual(['src', 'readme']);
616
+ });
617
+
618
+ it('toggling a leaf never marks it expanded', () => {
619
+ const {result} = renderHook(() =>
620
+ useTableTreeState({data: fileTree, idKey: 'id'}),
621
+ );
622
+ const readme = fileTree[1];
623
+
624
+ act(() => {
625
+ result.current.treeConfig.onToggleItem(readme);
626
+ });
627
+
628
+ expect(result.current.treeConfig.getRowMeta(readme)).toEqual({
629
+ id: 'readme',
630
+ level: 0,
631
+ hasChildren: false,
632
+ isExpanded: false,
633
+ });
634
+ expect(ids(result.current.visibleData)).toEqual(['src', 'readme']);
635
+ });
636
+ });