@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,594 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useTableTreeData.test.tsx
5
+ * @input Uses vitest, @testing-library/react, Table components
6
+ * @output Unit tests for the useTableTreeData plugin
7
+ * @position Testing; validates tree column decoration, expander behavior,
8
+ * row ARIA, and the flat-data migration no-op
9
+ */
10
+
11
+ import {describe, it, expect, vi} from 'vitest';
12
+ import {render, screen, within} from '@testing-library/react';
13
+ import userEvent from '@testing-library/user-event';
14
+ import {useState} from 'react';
15
+ import {Table} from '../../Table';
16
+ import type {TableColumn} from '../../types';
17
+ import {useTableSelection, useTableSelectionState} from '../selection';
18
+ import {useTableSortableState} from '../sortable';
19
+ import {useTableTreeData} from './useTableTreeData';
20
+ import {useTableTreeState} from './useTableTreeState';
21
+ import type {UseTableTreeStateConfig} from './useTableTreeState';
22
+
23
+ // =============================================================================
24
+ // Test Data
25
+ // =============================================================================
26
+
27
+ interface FileRow extends Record<string, unknown> {
28
+ id: string;
29
+ name: string;
30
+ size: number;
31
+ children?: FileRow[];
32
+ }
33
+
34
+ const fileTree: FileRow[] = [
35
+ {
36
+ id: 'src',
37
+ name: 'src',
38
+ size: 0,
39
+ children: [
40
+ {
41
+ id: 'components',
42
+ name: 'components',
43
+ size: 0,
44
+ children: [{id: 'button', name: 'Button.tsx', size: 120}],
45
+ },
46
+ {id: 'utils', name: 'utils.ts', size: 40},
47
+ ],
48
+ },
49
+ {id: 'readme', name: 'README.md', size: 10},
50
+ ];
51
+
52
+ const columns: TableColumn<FileRow>[] = [
53
+ {key: 'name', header: 'Name'},
54
+ {key: 'size', header: 'Size'},
55
+ ];
56
+
57
+ function TreeTable(
58
+ props: Partial<UseTableTreeStateConfig<FileRow>> & {data?: FileRow[]},
59
+ ) {
60
+ const {visibleData, treeConfig} = useTableTreeState<FileRow>({
61
+ data: props.data ?? fileTree,
62
+ idKey: 'id',
63
+ ...props,
64
+ });
65
+ const tree = useTableTreeData(treeConfig);
66
+
67
+ return (
68
+ <Table data={visibleData} columns={columns} idKey="id" plugins={{tree}} />
69
+ );
70
+ }
71
+
72
+ /** All body rows (skips the header row). */
73
+ function getBodyRows(): HTMLElement[] {
74
+ return screen.getAllByRole('row').slice(1);
75
+ }
76
+
77
+ function getRowByText(text: string): HTMLElement {
78
+ const row = getBodyRows().find(r => within(r).queryByText(text));
79
+ if (!row) {
80
+ throw new Error(`no row containing "${text}"`);
81
+ }
82
+ return row;
83
+ }
84
+
85
+ // =============================================================================
86
+ // Expander button
87
+ // =============================================================================
88
+
89
+ describe('useTableTreeData — expander', () => {
90
+ it('renders an "Expand row" button on collapsed expandable rows only', () => {
91
+ render(<TreeTable />);
92
+
93
+ const srcRow = getRowByText('src');
94
+ expect(
95
+ within(srcRow).getByRole('button', {name: 'Expand row'}),
96
+ ).toBeInTheDocument();
97
+
98
+ const readmeRow = getRowByText('README.md');
99
+ expect(within(readmeRow).queryByRole('button')).toBeNull();
100
+ });
101
+
102
+ it('expands children on click and relabels the button "Collapse row"', async () => {
103
+ const user = userEvent.setup();
104
+ render(<TreeTable />);
105
+
106
+ expect(screen.queryByText('components')).toBeNull();
107
+
108
+ await user.click(
109
+ within(getRowByText('src')).getByRole('button', {name: 'Expand row'}),
110
+ );
111
+
112
+ expect(screen.getByText('components')).toBeInTheDocument();
113
+ expect(screen.getByText('utils.ts')).toBeInTheDocument();
114
+ expect(
115
+ within(getRowByText('src')).getByRole('button', {name: 'Collapse row'}),
116
+ ).toBeInTheDocument();
117
+ });
118
+
119
+ it('collapses an expanded row on click, unmounting the subtree', async () => {
120
+ const user = userEvent.setup();
121
+ render(<TreeTable defaultExpandedIds={['src', 'components']} />);
122
+
123
+ expect(screen.getByText('Button.tsx')).toBeInTheDocument();
124
+
125
+ await user.click(
126
+ within(getRowByText('src')).getByRole('button', {name: 'Collapse row'}),
127
+ );
128
+
129
+ expect(screen.queryByText('components')).toBeNull();
130
+ expect(screen.queryByText('Button.tsx')).toBeNull();
131
+ // Unmounted, not hidden: only roots + header remain.
132
+ expect(screen.getAllByRole('row')).toHaveLength(3);
133
+ });
134
+
135
+ it('sets aria-expanded on the expander button', async () => {
136
+ const user = userEvent.setup();
137
+ render(<TreeTable />);
138
+
139
+ const button = within(getRowByText('src')).getByRole('button', {
140
+ name: 'Expand row',
141
+ });
142
+ expect(button).toHaveAttribute('aria-expanded', 'false');
143
+
144
+ await user.click(button);
145
+
146
+ expect(
147
+ within(getRowByText('src')).getByRole('button', {name: 'Collapse row'}),
148
+ ).toHaveAttribute('aria-expanded', 'true');
149
+ });
150
+ });
151
+
152
+ // =============================================================================
153
+ // Row ARIA
154
+ // =============================================================================
155
+
156
+ describe('useTableTreeData — row ARIA', () => {
157
+ it('sets 1-based aria-level on every body row', () => {
158
+ render(<TreeTable defaultExpandedIds={['src', 'components']} />);
159
+
160
+ expect(getRowByText('src')).toHaveAttribute('aria-level', '1');
161
+ expect(getRowByText('components')).toHaveAttribute('aria-level', '2');
162
+ expect(getRowByText('Button.tsx')).toHaveAttribute('aria-level', '3');
163
+ expect(getRowByText('README.md')).toHaveAttribute('aria-level', '1');
164
+ });
165
+
166
+ it('sets aria-expanded on expandable rows and omits it on leaves', () => {
167
+ render(<TreeTable defaultExpandedIds={['src']} />);
168
+
169
+ expect(getRowByText('src')).toHaveAttribute('aria-expanded', 'true');
170
+ expect(getRowByText('components')).toHaveAttribute(
171
+ 'aria-expanded',
172
+ 'false',
173
+ );
174
+ expect(getRowByText('utils.ts')).not.toHaveAttribute('aria-expanded');
175
+ expect(getRowByText('README.md')).not.toHaveAttribute('aria-expanded');
176
+ });
177
+ });
178
+
179
+ // =============================================================================
180
+ // Migration no-op (flat data)
181
+ // =============================================================================
182
+
183
+ describe('useTableTreeData — flat data is a no-op', () => {
184
+ const flat: FileRow[] = [
185
+ {id: 'a', name: 'alpha', size: 1},
186
+ {id: 'b', name: 'beta', size: 2},
187
+ ];
188
+
189
+ it('renders no expanders, spacers, or tree ARIA for flat data', () => {
190
+ render(<TreeTable data={flat} />);
191
+
192
+ expect(screen.queryByRole('button')).toBeNull();
193
+ for (const row of getBodyRows()) {
194
+ expect(row).not.toHaveAttribute('aria-level');
195
+ expect(row).not.toHaveAttribute('aria-expanded');
196
+ }
197
+ });
198
+
199
+ it('renders first-column cell content identically to a plugin-free Table', () => {
200
+ const {container: withPlugin} = render(<TreeTable data={flat} />);
201
+ const pluginCell = withPlugin.querySelector('tbody td');
202
+
203
+ const {container: without} = render(
204
+ <Table data={flat} columns={columns} idKey="id" />,
205
+ );
206
+ const plainCell = without.querySelector('tbody td');
207
+
208
+ expect(pluginCell).toBeTruthy();
209
+ expect(plainCell).toBeTruthy();
210
+ expect(pluginCell?.innerHTML).toBe(plainCell?.innerHTML);
211
+ });
212
+ });
213
+
214
+ // =============================================================================
215
+ // Stability across data-shape changes
216
+ // =============================================================================
217
+
218
+ describe('useTableTreeData — stability when the data shape changes', () => {
219
+ const flat: FileRow[] = [
220
+ {id: 'a', name: 'alpha', size: 1},
221
+ {id: 'b', name: 'beta', size: 2},
222
+ ];
223
+
224
+ it('does not remount the table when flat data becomes nested', () => {
225
+ const {container, rerender} = render(<TreeTable data={flat} />);
226
+ const tableBefore = container.querySelector('table');
227
+
228
+ rerender(<TreeTable data={fileTree} />);
229
+
230
+ expect(container.querySelector('table')).toBe(tableBefore);
231
+ // The tree affordance appears without a remount.
232
+ expect(
233
+ screen.getAllByRole('button', {name: 'Expand row'}).length,
234
+ ).toBeGreaterThan(0);
235
+ });
236
+
237
+ it('does not warn about an empty plugin for flat data', () => {
238
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
239
+ try {
240
+ render(<TreeTable data={flat} />);
241
+ const pluginWarnings = warn.mock.calls.filter(args =>
242
+ String(args[0]).includes('no transform methods'),
243
+ );
244
+ expect(pluginWarnings).toHaveLength(0);
245
+ } finally {
246
+ warn.mockRestore();
247
+ }
248
+ });
249
+
250
+ it('removes tree ARIA from rows when nested data becomes flat', () => {
251
+ const {rerender} = render(
252
+ <TreeTable data={fileTree} defaultExpandedIds={['src']} />,
253
+ );
254
+ expect(getRowByText('src')).toHaveAttribute('aria-level', '1');
255
+
256
+ rerender(<TreeTable data={flat} />);
257
+
258
+ for (const row of getBodyRows()) {
259
+ expect(row).not.toHaveAttribute('aria-level');
260
+ expect(row).not.toHaveAttribute('aria-expanded');
261
+ }
262
+ });
263
+ });
264
+
265
+ // =============================================================================
266
+ // Toggle batching
267
+ // =============================================================================
268
+
269
+ describe('useTableTreeState — batched toggles', () => {
270
+ it('applies two different toggles landing in the same React batch', async () => {
271
+ const user = userEvent.setup();
272
+
273
+ function TwoTogglesTable() {
274
+ const {visibleData, treeConfig, expandAll} = useTableTreeState<FileRow>({
275
+ data: fileTree,
276
+ idKey: 'id',
277
+ });
278
+ const tree = useTableTreeData(treeConfig);
279
+ return (
280
+ <>
281
+ <button
282
+ type="button"
283
+ onClick={() => {
284
+ // Two commits in one event handler — one React batch.
285
+ treeConfig.onToggleItem(fileTree[0]); // expand 'src'
286
+ treeConfig.onToggleItem(fileTree[0].children![0]); // expand 'components'
287
+ }}>
288
+ expand two
289
+ </button>
290
+ <Table
291
+ data={visibleData}
292
+ columns={columns}
293
+ idKey="id"
294
+ plugins={{tree}}
295
+ />
296
+ <button type="button" onClick={expandAll}>
297
+ noop
298
+ </button>
299
+ </>
300
+ );
301
+ }
302
+
303
+ render(<TwoTogglesTable />);
304
+
305
+ await user.click(screen.getByRole('button', {name: 'expand two'}));
306
+
307
+ // Both toggles must survive the batch.
308
+ expect(screen.getByText('components')).toBeInTheDocument();
309
+ expect(screen.getByText('Button.tsx')).toBeInTheDocument();
310
+ });
311
+ });
312
+
313
+ // =============================================================================
314
+ // Indentation
315
+ // =============================================================================
316
+
317
+ describe('useTableTreeData — indentation', () => {
318
+ it('indents nested rows by calc(level * step) and leaves roots unindented', () => {
319
+ render(<TreeTable defaultExpandedIds={['src', 'components']} />);
320
+
321
+ const cellWrapper = (text: string) =>
322
+ within(getRowByText(text)).getByText(text).closest('td')
323
+ ?.firstElementChild as HTMLElement;
324
+
325
+ expect(cellWrapper('src').getAttribute('style') ?? '').not.toContain(
326
+ 'calc',
327
+ );
328
+ expect(cellWrapper('components').getAttribute('style')).toContain(
329
+ 'calc(1 *',
330
+ );
331
+ expect(cellWrapper('Button.tsx').getAttribute('style')).toContain(
332
+ 'calc(2 *',
333
+ );
334
+ });
335
+
336
+ it('respects the indent token size', () => {
337
+ render(<TreeTable defaultExpandedIds={['src']} indent="lg" />);
338
+
339
+ const wrapper = within(getRowByText('utils.ts'))
340
+ .getByText('utils.ts')
341
+ .closest('td')?.firstElementChild as HTMLElement;
342
+ // lg maps to the --spacing-6 token
343
+ expect(wrapper.getAttribute('style')).toContain('--spacing-6');
344
+ });
345
+
346
+ it('never indents with a physical inline padding (logical property lives in the StyleX class)', () => {
347
+ // The indent is a StyleX dynamic style: the inline style carries only
348
+ // the CSS variable with the calc value; the paddingInlineStart property
349
+ // itself is compiled into the class. Guard against a regression to
350
+ // physical inline padding, which would not mirror in RTL.
351
+ render(<TreeTable defaultExpandedIds={['src']} />);
352
+
353
+ const wrapper = within(getRowByText('utils.ts'))
354
+ .getByText('utils.ts')
355
+ .closest('td')?.firstElementChild as HTMLElement;
356
+ const style = wrapper.getAttribute('style') ?? '';
357
+ expect(style).toContain('calc(1 *');
358
+ expect(style).not.toContain('padding-left');
359
+ expect(style).not.toContain('margin-left');
360
+ });
361
+
362
+ it('supports deep nesting with no depth cap', () => {
363
+ // a > b > c > d > e (levels 0..4)
364
+ let node: FileRow = {id: 'e', name: 'leaf-e', size: 0};
365
+ for (const id of ['d', 'c', 'b', 'a']) {
366
+ node = {id, name: `node-${id}`, size: 0, children: [node]};
367
+ }
368
+ render(
369
+ <TreeTable data={[node]} defaultExpandedIds={['a', 'b', 'c', 'd']} />,
370
+ );
371
+
372
+ const leafRow = getRowByText('leaf-e');
373
+ expect(leafRow).toHaveAttribute('aria-level', '5');
374
+ const wrapper = within(leafRow).getByText('leaf-e').closest('td')
375
+ ?.firstElementChild as HTMLElement;
376
+ expect(wrapper.getAttribute('style')).toContain('calc(4 *');
377
+ });
378
+ });
379
+
380
+ // =============================================================================
381
+ // treeColumnKey
382
+ // =============================================================================
383
+
384
+ describe('useTableTreeData — treeColumnKey', () => {
385
+ it('moves the expander into the configured column', () => {
386
+ render(<TreeTable treeColumnKey="size" />);
387
+
388
+ const srcRow = getRowByText('src');
389
+ const cells = within(srcRow).getAllByRole('cell');
390
+ // name column carries no expander; size column does
391
+ expect(within(cells[0]).queryByRole('button')).toBeNull();
392
+ expect(
393
+ within(cells[1]).getByRole('button', {name: 'Expand row'}),
394
+ ).toBeInTheDocument();
395
+ });
396
+
397
+ it('falls back to the first column when the configured column is absent', () => {
398
+ // e.g. columnSettings hid the configured tree column — the expander
399
+ // must not vanish while rows still announce aria-expanded.
400
+ render(<TreeTable treeColumnKey="not-a-column" />);
401
+
402
+ const srcRow = getRowByText('src');
403
+ const cells = within(srcRow).getAllByRole('cell');
404
+ expect(
405
+ within(cells[0]).getByRole('button', {name: 'Expand row'}),
406
+ ).toBeInTheDocument();
407
+ });
408
+ });
409
+
410
+ // =============================================================================
411
+ // Lazy loading
412
+ // =============================================================================
413
+
414
+ describe('useTableTreeData — lazy loading', () => {
415
+ it('shows an expander before children exist and reveals them once loaded', async () => {
416
+ const user = userEvent.setup();
417
+
418
+ function LazyTable() {
419
+ const [data, setData] = useState<FileRow[]>([
420
+ {id: 'folder', name: 'folder', size: 0},
421
+ ]);
422
+ const {visibleData, treeConfig} = useTableTreeState<FileRow>({
423
+ data,
424
+ idKey: 'id',
425
+ isItemExpandable: item => item.id === 'folder',
426
+ onExpandedIdsChange: ids => {
427
+ if (ids.has('folder')) {
428
+ // Simulated fetch: children arrive after expansion.
429
+ setData([
430
+ {
431
+ id: 'folder',
432
+ name: 'folder',
433
+ size: 0,
434
+ children: [{id: 'lazy-child', name: 'lazy-child', size: 3}],
435
+ },
436
+ ]);
437
+ }
438
+ },
439
+ });
440
+ const tree = useTableTreeData(treeConfig);
441
+ return (
442
+ <Table
443
+ data={visibleData}
444
+ columns={columns}
445
+ idKey="id"
446
+ plugins={{tree}}
447
+ />
448
+ );
449
+ }
450
+
451
+ render(<LazyTable />);
452
+
453
+ await user.click(screen.getByRole('button', {name: 'Expand row'}));
454
+
455
+ expect(screen.getByText('lazy-child')).toBeInTheDocument();
456
+ });
457
+ });
458
+
459
+ // =============================================================================
460
+ // Degenerate configurations
461
+ // =============================================================================
462
+
463
+ describe('useTableTreeData — degenerate configurations', () => {
464
+ it('renders without crashing when the table has zero columns', () => {
465
+ function ZeroColumnsTable() {
466
+ const {visibleData, treeConfig} = useTableTreeState<FileRow>({
467
+ data: fileTree,
468
+ idKey: 'id',
469
+ });
470
+ const tree = useTableTreeData(treeConfig);
471
+ return (
472
+ <Table data={visibleData} columns={[]} idKey="id" plugins={{tree}} />
473
+ );
474
+ }
475
+
476
+ expect(() => render(<ZeroColumnsTable />)).not.toThrow();
477
+ });
478
+
479
+ it('updates aria-level in place when a row is reparented deeper', () => {
480
+ const flat: FileRow[] = [
481
+ {
482
+ id: 'a',
483
+ name: 'alpha',
484
+ size: 0,
485
+ children: [{id: 'k', name: 'kid', size: 1}],
486
+ },
487
+ {id: 'm', name: 'mover', size: 2},
488
+ ];
489
+ const nested: FileRow[] = [
490
+ {
491
+ id: 'a',
492
+ name: 'alpha',
493
+ size: 0,
494
+ children: [
495
+ {id: 'k', name: 'kid', size: 1},
496
+ {id: 'm', name: 'mover', size: 2},
497
+ ],
498
+ },
499
+ ];
500
+
501
+ const {rerender} = render(
502
+ <TreeTable data={flat} defaultExpandedIds={['a']} />,
503
+ );
504
+ expect(getRowByText('mover')).toHaveAttribute('aria-level', '1');
505
+
506
+ rerender(<TreeTable data={nested} defaultExpandedIds={['a']} />);
507
+
508
+ expect(getRowByText('mover')).toHaveAttribute('aria-level', '2');
509
+ });
510
+ });
511
+
512
+ // =============================================================================
513
+ // Composition with selection
514
+ // =============================================================================
515
+
516
+ describe('useTableTreeData — composition with selection', () => {
517
+ it('prepends the selection checkbox column before the tree column', () => {
518
+ function ComposedTable() {
519
+ const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
520
+ () => new Set(),
521
+ );
522
+ const {visibleData, treeConfig} = useTableTreeState<FileRow>({
523
+ data: fileTree,
524
+ idKey: 'id',
525
+ defaultExpandedIds: ['src'],
526
+ });
527
+ const {selectionConfig} = useTableSelectionState({
528
+ data: visibleData,
529
+ idKey: 'id',
530
+ selectedKeys,
531
+ setSelectedKeys,
532
+ });
533
+ const tree = useTableTreeData(treeConfig);
534
+ const selection = useTableSelection(selectionConfig);
535
+ return (
536
+ <Table
537
+ data={visibleData}
538
+ columns={columns}
539
+ idKey="id"
540
+ plugins={{tree, selection}}
541
+ />
542
+ );
543
+ }
544
+
545
+ render(<ComposedTable />);
546
+
547
+ const srcRow = getRowByText('src');
548
+ const cells = within(srcRow).getAllByRole('cell');
549
+ // Checkbox column first, then the tree (name) column with the expander.
550
+ expect(within(cells[0]).getByRole('checkbox')).toBeInTheDocument();
551
+ expect(
552
+ within(cells[1]).getByRole('button', {name: 'Collapse row'}),
553
+ ).toBeInTheDocument();
554
+ });
555
+ });
556
+
557
+ // =============================================================================
558
+ // Composition with sorting
559
+ // =============================================================================
560
+
561
+ describe('useTableTreeData — composition with sorting', () => {
562
+ it('sorts sibling groups via applySort without interleaving levels', () => {
563
+ function SortedTree() {
564
+ const {applySort} = useTableSortableState<FileRow>({
565
+ data: fileTree,
566
+ defaultSort: [{sortKey: 'name', direction: 'descending'}],
567
+ });
568
+ const {visibleData, treeConfig} = useTableTreeState<FileRow>({
569
+ data: fileTree,
570
+ idKey: 'id',
571
+ defaultExpandedIds: ['src'],
572
+ sortSiblings: applySort,
573
+ });
574
+ const tree = useTableTreeData(treeConfig);
575
+ return (
576
+ <Table
577
+ data={visibleData}
578
+ columns={columns}
579
+ idKey="id"
580
+ plugins={{tree}}
581
+ />
582
+ );
583
+ }
584
+
585
+ render(<SortedTree />);
586
+
587
+ const names = getBodyRows().map(
588
+ row => within(row).getAllByRole('cell')[0].textContent,
589
+ );
590
+ // Roots desc: src > README.md; src's children desc: utils.ts > components.
591
+ // Children stay directly under their parent.
592
+ expect(names).toEqual(['src', 'utils.ts', 'components', 'README.md']);
593
+ });
594
+ });