@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
@@ -32,6 +32,7 @@ export {
32
32
  useTableRowExpansion,
33
33
  useTableRowExpansionState,
34
34
  } from './plugins/rowExpansion';
35
+ export {useTableTreeData, useTableTreeState} from './plugins/tree';
35
36
  export {resolveContextActions} from './tableContextMenu';
36
37
  export {
37
38
  useTableFiltering,
@@ -109,6 +110,12 @@ export type {
109
110
  UseTableGroupedRowsConfig,
110
111
  UseTableGroupedRowsResult,
111
112
  } from './plugins/groupedRows';
113
+ export type {
114
+ TableTreeRowMeta,
115
+ UseTableTreeDataConfig,
116
+ UseTableTreeStateConfig,
117
+ UseTableTreeStateResult,
118
+ } from './plugins/tree';
112
119
  export type {
113
120
  UseTableFilteringConfig,
114
121
  TableFilterState,
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
3
  import {describe, it, expect, vi, beforeEach} from 'vitest';
4
- import {render, screen, fireEvent} from '@testing-library/react';
4
+ import {render, renderHook, screen, fireEvent} from '@testing-library/react';
5
5
  import {useState} from 'react';
6
6
  import {Table} from '../../Table';
7
7
  import type {TableColumn} from '../../types';
@@ -153,3 +153,92 @@ describe('useTableRowExpansion', () => {
153
153
  expect(screen.queryByText('File B1')).not.toBeInTheDocument();
154
154
  });
155
155
  });
156
+
157
+ describe('useTableRowExpansionState cycle guard', () => {
158
+ /** A row whose children array contains the row itself (plus a real child). */
159
+ function makeSelfReferential(): TreeItem[] {
160
+ const x: TreeItem = {id: 'x', name: 'Self', children: []};
161
+ const y: TreeItem = {id: 'y', name: 'Leaf Y', children: []};
162
+ x.children.push(x, y);
163
+ return [x];
164
+ }
165
+
166
+ /** root -> child -> grand, where grand's children point back at root. */
167
+ function makeDeepCycle(): TreeItem[] {
168
+ const root: TreeItem = {id: 'root', name: 'Root', children: []};
169
+ const child: TreeItem = {id: 'child', name: 'Child', children: []};
170
+ const grand: TreeItem = {id: 'grand', name: 'Grand', children: []};
171
+ root.children.push(child);
172
+ child.children.push(grand);
173
+ grand.children.push(root);
174
+ return [root];
175
+ }
176
+
177
+ function renderState(baseData: TreeItem[], expandedKeys: Set<string>) {
178
+ const setExpandedKeys = vi.fn();
179
+ const hook = renderHook(() =>
180
+ useTableRowExpansionState<TreeItem>({
181
+ baseData,
182
+ getChildren: item => item.children,
183
+ getRowKey: item => item.id,
184
+ expandedKeys,
185
+ setExpandedKeys,
186
+ }),
187
+ );
188
+ return {hook, setExpandedKeys};
189
+ }
190
+
191
+ it('terminates on a self-referential expanded row and flattens each key once', () => {
192
+ const {hook} = renderState(makeSelfReferential(), new Set(['x']));
193
+ const {data, expansionConfig} = hook.result.current;
194
+ expect(data.map(item => item.id)).toEqual(['x', 'y']);
195
+ expect(expansionConfig.getDepth?.(data[0])).toBe(0);
196
+ expect(expansionConfig.getDepth?.(data[1])).toBe(1);
197
+ });
198
+
199
+ it('terminates on a deeper cycle back to the root and flattens each key once', () => {
200
+ const {hook} = renderState(
201
+ makeDeepCycle(),
202
+ new Set(['root', 'child', 'grand']),
203
+ );
204
+ const {data, expansionConfig} = hook.result.current;
205
+ expect(data.map(item => item.id)).toEqual(['root', 'child', 'grand']);
206
+ expect(expansionConfig.getDepth?.(data[0])).toBe(0);
207
+ expect(expansionConfig.getDepth?.(data[1])).toBe(1);
208
+ expect(expansionConfig.getDepth?.(data[2])).toBe(2);
209
+ // The cycle guard keeps isAllExpanded computable — true, not a crash.
210
+ expect(expansionConfig.isAllExpanded).toBe(true);
211
+ });
212
+
213
+ it('terminates when collecting allExpandableKeys on cyclic data with nothing expanded', () => {
214
+ const {hook, setExpandedKeys} = renderState(
215
+ makeDeepCycle(),
216
+ new Set<string>(),
217
+ );
218
+ const {data, expansionConfig} = hook.result.current;
219
+ expect(data.map(item => item.id)).toEqual(['root']);
220
+ expansionConfig.onToggleExpandAll?.(true);
221
+ const nextKeys = setExpandedKeys.mock.calls[0][0] as Set<string>;
222
+ expect(Array.from(nextKeys)).toEqual(['root', 'child', 'grand']);
223
+ });
224
+
225
+ it('re-walks a shared child under each expanded parent (ancestor-path, not visited-set, semantics)', () => {
226
+ const leaf: TreeItem = {id: 'leaf', name: 'Leaf', children: []};
227
+ const shared: TreeItem = {id: 's', name: 'Shared', children: [leaf]};
228
+ const p1: TreeItem = {id: 'p1', name: 'Parent 1', children: [shared]};
229
+ const p2: TreeItem = {id: 'p2', name: 'Parent 2', children: [shared]};
230
+ const {hook} = renderState([p1, p2], new Set(['p1', 'p2', 's']));
231
+ // 's' is on neither parent's ancestor path, so it must flatten under
232
+ // both — the guard only skips true cycles, matching pre-guard behavior
233
+ // for acyclic (DAG-shaped) data.
234
+ const {data} = hook.result.current;
235
+ expect(data.map(item => item.id)).toEqual([
236
+ 'p1',
237
+ 's',
238
+ 'leaf',
239
+ 'p2',
240
+ 's',
241
+ 'leaf',
242
+ ]);
243
+ });
244
+ });
@@ -141,12 +141,19 @@ export function useTableRowExpansionState<T extends Record<string, unknown>>({
141
141
 
142
142
  const depthMap = useMemo(() => {
143
143
  const map = new Map<string, number>();
144
+ // Ancestor keys on the current walk path — guards against cyclic data.
145
+ const path = new Set<string>();
144
146
  function walk(items: T[], depth: number) {
145
147
  for (const item of items) {
146
148
  const key = getRowKey(item);
149
+ if (path.has(key)) {
150
+ continue; // cyclic edge — skip
151
+ }
147
152
  map.set(key, depth);
148
153
  if (expandedKeys.has(key)) {
154
+ path.add(key);
149
155
  walk(getChildren(item), depth + 1);
156
+ path.delete(key);
150
157
  }
151
158
  }
152
159
  }
@@ -156,12 +163,19 @@ export function useTableRowExpansionState<T extends Record<string, unknown>>({
156
163
 
157
164
  const data = useMemo(() => {
158
165
  const result: T[] = [];
166
+ // Ancestor keys on the current walk path — guards against cyclic data.
167
+ const path = new Set<string>();
159
168
  function walk(items: T[]) {
160
169
  for (const item of items) {
161
- result.push(item);
162
170
  const key = getRowKey(item);
171
+ if (path.has(key)) {
172
+ continue; // cyclic edge — skip
173
+ }
174
+ result.push(item);
163
175
  if (expandedKeys.has(key)) {
176
+ path.add(key);
164
177
  walk(getChildren(item));
178
+ path.delete(key);
165
179
  }
166
180
  }
167
181
  }
@@ -172,11 +186,19 @@ export function useTableRowExpansionState<T extends Record<string, unknown>>({
172
186
  // Every expandable key across the whole tree (drives expand-all).
173
187
  const allExpandableKeys = useMemo(() => {
174
188
  const keys: string[] = [];
189
+ // Ancestor keys on the current walk path — guards against cyclic data.
190
+ const path = new Set<string>();
175
191
  function walk(items: T[]) {
176
192
  for (const item of items) {
193
+ const key = getRowKey(item);
194
+ if (path.has(key)) {
195
+ continue; // cyclic edge — skip
196
+ }
177
197
  if (isExpandable(item)) {
178
- keys.push(getRowKey(item));
198
+ keys.push(key);
199
+ path.add(key);
179
200
  walk(getChildren(item));
201
+ path.delete(key);
180
202
  }
181
203
  }
182
204
  }
@@ -399,7 +421,11 @@ export function useTableRowExpansion<T extends Record<string, unknown>>(
399
421
  <ExpansionChevron
400
422
  isExpanded={isExpanded}
401
423
  onToggle={() => onToggle(key)}
402
- ariaLabel={isExpanded ? t('@astryx.tableRowExpansion.collapseRow') : t('@astryx.tableRowExpansion.expandRow')}
424
+ ariaLabel={
425
+ isExpanded
426
+ ? t('@astryx.tableRowExpansion.collapseRow')
427
+ : t('@astryx.tableRowExpansion.expandRow')
428
+ }
403
429
  />
404
430
  );
405
431
  },
@@ -457,7 +483,11 @@ export function useTableRowExpansion<T extends Record<string, unknown>>(
457
483
  <ExpansionChevron
458
484
  isExpanded={isExpanded}
459
485
  onToggle={() => onToggle(key)}
460
- ariaLabel={isExpanded ? t('@astryx.tableRowExpansion.collapseRow') : t('@astryx.tableRowExpansion.expandRow')}
486
+ ariaLabel={
487
+ isExpanded
488
+ ? t('@astryx.tableRowExpansion.collapseRow')
489
+ : t('@astryx.tableRowExpansion.expandRow')
490
+ }
461
491
  />
462
492
  ) : (
463
493
  <span {...stylex.props(expansionStyles.placeholder)} />
@@ -499,7 +529,9 @@ export function useTableRowExpansion<T extends Record<string, unknown>>(
499
529
  {...stylex.props(expansionStyles.chevronButton)}
500
530
  onClick={() => onToggleExpandAll(!allExpanded)}
501
531
  aria-label={
502
- allExpanded ? t('@astryx.tableRowExpansion.collapseAllRows') : t('@astryx.tableRowExpansion.expandAllRows')
532
+ allExpanded
533
+ ? t('@astryx.tableRowExpansion.collapseAllRows')
534
+ : t('@astryx.tableRowExpansion.expandAllRows')
503
535
  }>
504
536
  <span
505
537
  {...stylex.props(
@@ -12,11 +12,9 @@ import {render, screen, fireEvent} from '@testing-library/react';
12
12
  import userEvent from '@testing-library/user-event';
13
13
  import {useState} from 'react';
14
14
  import {Table} from '../../Table';
15
+ import {InternationalizationProvider} from '../../../i18n';
15
16
  import type {TableColumn} from '../../types';
16
- import {
17
- useTableSortable,
18
- type TableSortState,
19
- } from './useTableSortable';
17
+ import {useTableSortable, type TableSortState} from './useTableSortable';
20
18
 
21
19
  // =============================================================================
22
20
  // Test Data
@@ -73,9 +71,7 @@ function SortableTable({
73
71
  isMultiSortEnabled,
74
72
  });
75
73
 
76
- return (
77
- <Table data={data} columns={columns} plugins={{sort: sortPlugin}} />
78
- );
74
+ return <Table data={data} columns={columns} plugins={{sort: sortPlugin}} />;
79
75
  }
80
76
 
81
77
  // =============================================================================
@@ -672,11 +668,15 @@ describe('useTableSortable — context menu actions', () => {
672
668
 
673
669
  // Apply ascending → "Clear sort" now appears.
674
670
  fireEvent.click(
675
- screen.getAllByRole('menuitem', {name: 'Sort ascending', hidden: true})[0],
671
+ screen.getAllByRole('menuitem', {
672
+ name: 'Sort ascending',
673
+ hidden: true,
674
+ })[0],
676
675
  );
677
676
  fireEvent.contextMenu(screen.getByText('Name'));
678
677
  expect(
679
- screen.getAllByRole('menuitem', {name: 'Clear sort', hidden: true}).length,
678
+ screen.getAllByRole('menuitem', {name: 'Clear sort', hidden: true})
679
+ .length,
680
680
  ).toBeGreaterThan(0);
681
681
  });
682
682
 
@@ -709,3 +709,89 @@ describe('useTableSortable — context menu actions', () => {
709
709
  expect(onSortChange).toHaveBeenLastCalledWith([]);
710
710
  });
711
711
  });
712
+
713
+ // =============================================================================
714
+ // i18n (header-button aria-labels route through the catalog)
715
+ // =============================================================================
716
+
717
+ describe('useTableSortable — i18n', () => {
718
+ it('localizes the unsorted sort-by aria-label via @astryx.table.sort.sortBy', () => {
719
+ render(
720
+ <InternationalizationProvider
721
+ locale="fr"
722
+ overrides={{
723
+ fr: {'@astryx.table.sort.sortBy': 'Trier par {label}'},
724
+ }}>
725
+ <SortableTable />
726
+ </InternationalizationProvider>,
727
+ );
728
+
729
+ expect(
730
+ screen.getByRole('button', {name: 'Trier par Name'}),
731
+ ).toBeInTheDocument();
732
+ expect(
733
+ screen.getByRole('button', {name: 'Trier par Age'}),
734
+ ).toBeInTheDocument();
735
+ });
736
+
737
+ it('localizes the multi-sort priority aria-label with ICU number args', () => {
738
+ render(
739
+ <InternationalizationProvider
740
+ locale="fr"
741
+ overrides={{
742
+ fr: {
743
+ '@astryx.table.sort.sortedByWithPriority':
744
+ 'Trier par {label}, tri {direction}, priorité {rank, number} sur {total, number}',
745
+ '@astryx.table.sort.direction.ascending': 'croissant',
746
+ '@astryx.table.sort.direction.descending': 'décroissant',
747
+ },
748
+ }}>
749
+ <SortableTable
750
+ isMultiSortEnabled
751
+ initialSort={[
752
+ {sortKey: 'name', direction: 'ascending'},
753
+ {sortKey: 'age', direction: 'descending'},
754
+ ]}
755
+ />
756
+ </InternationalizationProvider>,
757
+ );
758
+
759
+ // The overridden direction words deliberately differ from the raw enum
760
+ // values ('ascending'/'descending'), so an implementation interpolating
761
+ // the enum without translating it cannot pass — for either direction.
762
+ expect(
763
+ screen.getByRole('button', {
764
+ name: 'Trier par Name, tri croissant, priorité 1 sur 2',
765
+ }),
766
+ ).toBeInTheDocument();
767
+ expect(
768
+ screen.getByRole('button', {
769
+ name: 'Trier par Age, tri décroissant, priorité 2 sur 2',
770
+ }),
771
+ ).toBeInTheDocument();
772
+ });
773
+
774
+ it('localizes the sorted aria-label and direction word via their own keys', () => {
775
+ render(
776
+ <InternationalizationProvider
777
+ locale="fr"
778
+ overrides={{
779
+ fr: {
780
+ '@astryx.table.sort.sortedBy': 'Trié par {label}, {direction}',
781
+ '@astryx.table.sort.direction.ascending': 'croissant',
782
+ },
783
+ }}>
784
+ <SortableTable
785
+ initialSort={[{sortKey: 'name', direction: 'ascending'}]}
786
+ />
787
+ </InternationalizationProvider>,
788
+ );
789
+
790
+ // Both the composed template and the direction word resolve through
791
+ // their own catalog keys; 'croissant' differs from the raw enum value,
792
+ // so neither a hardcoded English frame nor raw-enum interpolation passes.
793
+ expect(
794
+ screen.getByRole('button', {name: 'Trié par Name, croissant'}),
795
+ ).toBeInTheDocument();
796
+ });
797
+ });
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file useTableSortable.tsx
7
- * @input React, types, Icon, theme tokens
7
+ * @input React, types, Icon, theme tokens, i18n (useTranslator)
8
8
  * @output Exports useTableSortable hook and sort-related types
9
9
  * @position Sortable plugin; consumed by Table via plugins prop
10
10
  *
@@ -18,7 +18,7 @@ import * as stylex from '@stylexjs/stylex';
18
18
  import {colorVars, spacingVars, radiusVars} from '../../../theme/tokens.stylex';
19
19
  import {Icon} from '../../../Icon';
20
20
  import {resolveContextActions} from '../../tableContextMenu';
21
- import {useTranslator} from '../../../i18n';
21
+ import {useTranslator, type TranslatorFn} from '../../../i18n';
22
22
  import type {
23
23
  TablePlugin,
24
24
  HeaderCellRenderProps,
@@ -184,6 +184,7 @@ function getHeaderLabel<T extends Record<string, unknown>>(
184
184
  }
185
185
 
186
186
  function buildAriaLabel<T extends Record<string, unknown>>(
187
+ t: TranslatorFn,
187
188
  column: TableColumn<T>,
188
189
  direction: TableSortDirection | null,
189
190
  rank: number | null,
@@ -191,13 +192,21 @@ function buildAriaLabel<T extends Record<string, unknown>>(
191
192
  ): string {
192
193
  const label = getHeaderLabel(column);
193
194
  if (direction == null) {
194
- return `Sort by ${label}`;
195
+ return t('@astryx.table.sort.sortBy', {label});
195
196
  }
196
- let ariaLabel = `Sort by ${label}, sorted ${direction}`;
197
+ const directionLabel =
198
+ direction === 'ascending'
199
+ ? t('@astryx.table.sort.direction.ascending')
200
+ : t('@astryx.table.sort.direction.descending');
197
201
  if (rank != null && total > 1) {
198
- ariaLabel += `, priority ${rank} of ${total}`;
202
+ return t('@astryx.table.sort.sortedByWithPriority', {
203
+ label,
204
+ direction: directionLabel,
205
+ rank,
206
+ total,
207
+ });
199
208
  }
200
- return ariaLabel;
209
+ return t('@astryx.table.sort.sortedBy', {label, direction: directionLabel});
201
210
  }
202
211
 
203
212
  function getNextDirection(
@@ -227,6 +236,7 @@ function SortHeaderButton<T extends Record<string, unknown>>({
227
236
  children: ReactNode;
228
237
  configRef: React.RefObject<UseTableSortableConfig>;
229
238
  }) {
239
+ const t = useTranslator();
230
240
  const config = configRef.current;
231
241
  const sortKey = resolveSortKey(column) ?? '';
232
242
  const entryIndex = config.sort.findIndex(e => e.sortKey === sortKey);
@@ -244,7 +254,13 @@ function SortHeaderButton<T extends Record<string, unknown>>({
244
254
  ? 'arrowDown'
245
255
  : 'arrowsUpDown';
246
256
 
247
- const ariaLabel = buildAriaLabel(column, direction, rank, config.sort.length);
257
+ const ariaLabel = buildAriaLabel(
258
+ t,
259
+ column,
260
+ direction,
261
+ rank,
262
+ config.sort.length,
263
+ );
248
264
 
249
265
  const handleClick = (e: React.MouseEvent | React.KeyboardEvent) => {
250
266
  const cfg = configRef.current;
@@ -0,0 +1,12 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ export {useTableTreeData} from './useTableTreeData';
4
+ export type {
5
+ TableTreeRowMeta,
6
+ UseTableTreeDataConfig,
7
+ } from './useTableTreeData';
8
+ export {useTableTreeState} from './useTableTreeState';
9
+ export type {
10
+ UseTableTreeStateConfig,
11
+ UseTableTreeStateResult,
12
+ } from './useTableTreeState';
@@ -0,0 +1,163 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useTableTreeData-perf.test.tsx
5
+ * @input Table, useTableTreeData, useTableTreeState, React testing utilities
6
+ * @output Performance tests for tree plugin render behavior
7
+ * @position Test file; validates toggle render efficiency and DOM cost
8
+ *
9
+ * The tree plugin uses an external store so a toggle re-renders only the
10
+ * affected cells — not the whole table body. Note: expanding inserts rows,
11
+ * which shifts the rowIndex of every row *after* the insertion point (the
12
+ * row memo comparator includes rowIndex by design), so the guarantee is
13
+ * scoped to rows before the toggled row.
14
+ */
15
+
16
+ import {describe, it, expect} from 'vitest';
17
+ import {render, screen, act} from '@testing-library/react';
18
+ import {useMemo} from 'react';
19
+ import userEvent from '@testing-library/user-event';
20
+ import {Table} from '../../Table';
21
+ import type {TableColumn} from '../../types';
22
+ import {useTableTreeData} from './useTableTreeData';
23
+ import {useTableTreeState} from './useTableTreeState';
24
+
25
+ interface TreeRow extends Record<string, unknown> {
26
+ id: string;
27
+ name: string;
28
+ children?: TreeRow[];
29
+ }
30
+
31
+ /** 5 roots; the last root has 2 children. */
32
+ const roots: TreeRow[] = [
33
+ ...Array.from({length: 4}, (_, i) => ({
34
+ id: `root-${i}`,
35
+ name: `Root ${i}`,
36
+ })),
37
+ {
38
+ id: 'root-4',
39
+ name: 'Root 4',
40
+ children: [
41
+ {id: 'child-0', name: 'Child 0'},
42
+ {id: 'child-1', name: 'Child 1'},
43
+ ],
44
+ },
45
+ ];
46
+
47
+ function TreeRenderCountTable({
48
+ renderCounts,
49
+ }: {
50
+ renderCounts: Record<string, number>;
51
+ }) {
52
+ const columns = useMemo<TableColumn<TreeRow>[]>(
53
+ () => [
54
+ {
55
+ key: 'name',
56
+ header: 'Name',
57
+ renderCell: (item: TreeRow) => {
58
+ renderCounts[item.id] = (renderCounts[item.id] ?? 0) + 1;
59
+ return item.name;
60
+ },
61
+ },
62
+ ],
63
+ [renderCounts],
64
+ );
65
+
66
+ const {visibleData, treeConfig} = useTableTreeState<TreeRow>({
67
+ data: roots,
68
+ idKey: 'id',
69
+ });
70
+ const tree = useTableTreeData(treeConfig);
71
+
72
+ return (
73
+ <Table data={visibleData} columns={columns} idKey="id" plugins={{tree}} />
74
+ );
75
+ }
76
+
77
+ describe('Tree plugin render performance', () => {
78
+ it('expanding the last root does not re-render preceding rows', async () => {
79
+ const user = userEvent.setup();
80
+ const renderCounts: Record<string, number> = {};
81
+
82
+ render(<TreeRenderCountTable renderCounts={renderCounts} />);
83
+
84
+ const initialCounts = {...renderCounts};
85
+ expect(Object.keys(initialCounts)).toHaveLength(5);
86
+
87
+ await act(async () => {
88
+ await user.click(screen.getByRole('button', {name: 'Expand row'}));
89
+ });
90
+
91
+ for (let i = 0; i < 4; i++) {
92
+ expect(renderCounts[`root-${i}`]).toBe(initialCounts[`root-${i}`]);
93
+ }
94
+ // The children mounted exactly once.
95
+ expect(renderCounts['child-0']).toBe(1);
96
+ expect(renderCounts['child-1']).toBe(1);
97
+ });
98
+
99
+ it('collapsing unmounts the subtree rows entirely', async () => {
100
+ const user = userEvent.setup();
101
+ const renderCounts: Record<string, number> = {};
102
+
103
+ render(<TreeRenderCountTable renderCounts={renderCounts} />);
104
+
105
+ await act(async () => {
106
+ await user.click(screen.getByRole('button', {name: 'Expand row'}));
107
+ });
108
+ expect(screen.getAllByRole('row')).toHaveLength(8); // header + 5 roots + 2 children
109
+
110
+ await act(async () => {
111
+ await user.click(screen.getByRole('button', {name: 'Collapse row'}));
112
+ });
113
+ expect(screen.getAllByRole('row')).toHaveLength(6); // header + 5 roots
114
+ });
115
+
116
+ it('adds at most one wrapper, one expander/spacer to the tree column and zero DOM to other columns', () => {
117
+ const {container} = render(
118
+ <Table
119
+ data={
120
+ [{id: 'a', name: 'a', children: [{id: 'b', name: 'b'}]}] as TreeRow[]
121
+ }
122
+ columns={[
123
+ {key: 'name', header: 'Name'},
124
+ {key: 'id', header: 'Id'},
125
+ ]}
126
+ idKey="id"
127
+ plugins={{}}
128
+ />,
129
+ );
130
+ const plainSecondCellHTML =
131
+ container.querySelectorAll('tbody td')[1].innerHTML;
132
+
133
+ function DomBudgetTable() {
134
+ const {visibleData, treeConfig} = useTableTreeState<TreeRow>({
135
+ data: [{id: 'a', name: 'a', children: [{id: 'b', name: 'b'}]}],
136
+ idKey: 'id',
137
+ });
138
+ const tree = useTableTreeData(treeConfig);
139
+ return (
140
+ <Table
141
+ data={visibleData}
142
+ columns={[
143
+ {key: 'name', header: 'Name'},
144
+ {key: 'id', header: 'Id'},
145
+ ]}
146
+ idKey="id"
147
+ plugins={{tree}}
148
+ />
149
+ );
150
+ }
151
+ const {container: treeContainer} = render(<DomBudgetTable />);
152
+ const cells = treeContainer.querySelectorAll('tbody td');
153
+
154
+ // Tree column: one flex wrapper containing one expander button + text.
155
+ const wrapper = cells[0].firstElementChild as HTMLElement;
156
+ expect(wrapper.tagName).toBe('DIV');
157
+ expect(wrapper.children).toHaveLength(1);
158
+ expect(wrapper.querySelector('button')).not.toBeNull();
159
+
160
+ // Other columns: byte-identical to the plugin-free render.
161
+ expect(cells[1].innerHTML).toBe(plainSecondCellHTML);
162
+ });
163
+ });