@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.
- package/CHANGELOG.md +65 -0
- package/README.md +30 -19
- package/dist/Avatar/Avatar.d.ts +10 -5
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +12 -8
- package/dist/Avatar/AvatarSizeContext.d.ts +1 -1
- package/dist/Avatar/AvatarSizeContext.js +1 -1
- package/dist/Avatar/AvatarStatusDot.d.ts +2 -2
- package/dist/Avatar/AvatarStatusDot.js +2 -2
- package/dist/AvatarGroup/AvatarGroup.d.ts +2 -2
- package/dist/AvatarGroup/AvatarGroup.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroup.js +2 -2
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.d.ts.map +1 -1
- package/dist/AvatarGroup/AvatarGroupOverflow.js +16 -14
- package/dist/Carousel/Carousel.d.ts +3 -2
- package/dist/Carousel/Carousel.d.ts.map +1 -1
- package/dist/Carousel/Carousel.js +32 -2
- package/dist/Chat/ChatMessage.d.ts +1 -1
- package/dist/Chat/ChatMessage.d.ts.map +1 -1
- package/dist/Chat/ChatMessage.js +1 -1
- package/dist/Citation/Citation.d.ts.map +1 -1
- package/dist/Citation/Citation.js +10 -4
- package/dist/Collapsible/Collapsible.d.ts +11 -1
- package/dist/Collapsible/Collapsible.d.ts.map +1 -1
- package/dist/Collapsible/Collapsible.js +36 -4
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +4 -3
- package/dist/ContextMenu/index.d.ts +3 -0
- package/dist/ContextMenu/index.d.ts.map +1 -1
- package/dist/ContextMenu/index.js +7 -1
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +8 -4
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts +89 -0
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.d.ts.map +1 -0
- package/dist/DropdownMenu/DropdownMenuCheckboxItem.js +170 -0
- package/dist/DropdownMenu/DropdownMenuContext.d.ts +17 -1
- package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenuContext.js +18 -0
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +69 -0
- package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -0
- package/dist/DropdownMenu/DropdownMenuRadioGroup.js +70 -0
- package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts +69 -0
- package/dist/DropdownMenu/DropdownMenuRadioItem.d.ts.map +1 -0
- package/dist/DropdownMenu/DropdownMenuRadioItem.js +178 -0
- package/dist/DropdownMenu/index.d.ts +5 -1
- package/dist/DropdownMenu/index.d.ts.map +1 -1
- package/dist/DropdownMenu/index.js +10 -1
- package/dist/DropdownMenu/menuItemRoles.d.ts +13 -0
- package/dist/DropdownMenu/menuItemRoles.d.ts.map +1 -0
- package/dist/DropdownMenu/menuItemRoles.js +15 -0
- package/dist/Icon/Icon.d.ts +43 -13
- package/dist/Icon/Icon.d.ts.map +1 -1
- package/dist/Icon/Icon.js +62 -27
- package/dist/Outline/Outline.d.ts +69 -3
- package/dist/Outline/Outline.d.ts.map +1 -1
- package/dist/Outline/Outline.js +129 -26
- package/dist/Outline/useScrollSpy.d.ts +27 -7
- package/dist/Outline/useScrollSpy.d.ts.map +1 -1
- package/dist/Outline/useScrollSpy.js +202 -70
- package/dist/TabList/Tab.d.ts.map +1 -1
- package/dist/TabList/Tab.js +2 -2
- package/dist/TabList/TabList.d.ts.map +1 -1
- package/dist/TabList/TabList.js +2 -0
- package/dist/TabList/TabMenu.d.ts.map +1 -1
- package/dist/TabList/TabMenu.js +1 -1
- package/dist/Table/Table.d.ts +1 -1
- package/dist/Table/Table.js +1 -1
- package/dist/Table/index.d.ts +2 -0
- package/dist/Table/index.d.ts.map +1 -1
- package/dist/Table/index.js +1 -0
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +24 -2
- package/dist/Table/plugins/sortable/useTableSortable.d.ts.map +1 -1
- package/dist/Table/plugins/sortable/useTableSortable.js +18 -7
- package/dist/Table/plugins/tree/index.d.ts +5 -0
- package/dist/Table/plugins/tree/index.d.ts.map +1 -0
- package/dist/Table/plugins/tree/index.js +4 -0
- package/dist/Table/plugins/tree/useTableTreeData.d.ts +38 -0
- package/dist/Table/plugins/tree/useTableTreeData.d.ts.map +1 -0
- package/dist/Table/plugins/tree/useTableTreeData.js +358 -0
- package/dist/Table/plugins/tree/useTableTreeState.d.ts +51 -0
- package/dist/Table/plugins/tree/useTableTreeState.d.ts.map +1 -0
- package/dist/Table/plugins/tree/useTableTreeState.js +175 -0
- package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.js +6 -3
- package/dist/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/Toolbar/Toolbar.js +8 -1
- package/dist/TreeList/TreeListItem.d.ts.map +1 -1
- package/dist/TreeList/TreeListItem.js +5 -14
- package/dist/astryx.css +24 -4
- package/dist/astryx.umd.js +54 -54
- package/dist/astryx.umd.js.map +4 -4
- package/dist/theme/Theme.js +1 -1
- package/dist/theme/defineTheme.d.ts +1 -1
- package/dist/theme/defineTheme.js +1 -1
- package/dist/theme/hct.d.ts +0 -1
- package/dist/theme/hct.d.ts.map +1 -1
- package/dist/theme/hct.js +0 -3
- package/docs.mjs +32 -461
- package/locales/en.json +200 -172
- package/locales/pseudo.json +21 -0
- package/package.json +3 -1
- package/scripts/postinstall.mjs +93 -0
- package/src/Avatar/Avatar.doc.mjs +6 -6
- package/src/Avatar/Avatar.tsx +16 -11
- package/src/Avatar/AvatarSizeContext.ts +1 -1
- package/src/Avatar/AvatarStatusDot.tsx +2 -2
- package/src/AvatarGroup/AvatarGroup.doc.mjs +1 -1
- package/src/AvatarGroup/AvatarGroup.test.tsx +17 -3
- package/src/AvatarGroup/AvatarGroup.tsx +3 -3
- package/src/AvatarGroup/AvatarGroupOverflow.doc.mjs +4 -4
- package/src/AvatarGroup/AvatarGroupOverflow.tsx +21 -5
- package/src/Carousel/Carousel.doc.mjs +1 -0
- package/src/Carousel/Carousel.test.tsx +96 -1
- package/src/Carousel/Carousel.tsx +29 -2
- package/src/Chat/Chat.doc.mjs +1 -1
- package/src/Chat/ChatMessage.tsx +4 -2
- package/src/CheckboxInput/CheckboxInput.doc.mjs +1 -1
- package/src/Citation/Citation.test.tsx +51 -0
- package/src/Citation/Citation.tsx +8 -2
- package/src/Collapsible/Collapsible.doc.mjs +7 -0
- package/src/Collapsible/Collapsible.test.tsx +80 -0
- package/src/Collapsible/Collapsible.tsx +58 -5
- package/src/ContextMenu/ContextMenu.test.tsx +75 -1
- package/src/ContextMenu/ContextMenu.tsx +10 -5
- package/src/ContextMenu/index.ts +15 -0
- package/src/DropdownMenu/DropdownMenu.doc.mjs +23 -0
- package/src/DropdownMenu/DropdownMenu.tsx +11 -6
- package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +95 -0
- package/src/DropdownMenu/DropdownMenuCheckboxItem.tsx +237 -0
- package/src/DropdownMenu/DropdownMenuContext.tsx +29 -1
- package/src/DropdownMenu/DropdownMenuItem.doc.mjs +7 -1
- package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +80 -0
- package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +119 -0
- package/src/DropdownMenu/DropdownMenuRadioItem.doc.mjs +80 -0
- package/src/DropdownMenu/DropdownMenuRadioItem.tsx +232 -0
- package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +163 -0
- package/src/DropdownMenu/index.ts +23 -3
- package/src/DropdownMenu/menuItemRoles.ts +22 -0
- package/src/Field/Field.doc.mjs +1 -1
- package/src/Field/FieldLabel.doc.mjs +1 -1
- package/src/Icon/Icon.doc.mjs +12 -0
- package/src/Icon/Icon.test.tsx +142 -0
- package/src/Icon/Icon.tsx +98 -30
- package/src/Item/Item.doc.mjs +1 -1
- package/src/NumberInput/NumberInput.doc.mjs +2 -2
- package/src/Outline/Outline.doc.mjs +123 -3
- package/src/Outline/Outline.test.tsx +735 -0
- package/src/Outline/Outline.tsx +184 -33
- package/src/Outline/useScrollSpy.ts +248 -72
- package/src/Selector/SelectorOption.doc.mjs +1 -1
- package/src/SideNav/SideNavItem.doc.mjs +2 -2
- package/src/Switch/Switch.doc.mjs +1 -1
- package/src/TabList/Tab.tsx +5 -1
- package/src/TabList/TabList.doc.mjs +3 -3
- package/src/TabList/TabList.test.tsx +66 -0
- package/src/TabList/TabList.tsx +8 -0
- package/src/TabList/TabMenu.tsx +4 -1
- package/src/Table/Table.doc.mjs +2 -0
- package/src/Table/Table.tsx +1 -1
- package/src/Table/index.ts +7 -0
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +90 -1
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +37 -5
- package/src/Table/plugins/sortable/useTableSortable.test.tsx +95 -9
- package/src/Table/plugins/sortable/useTableSortable.tsx +23 -7
- package/src/Table/plugins/tree/index.ts +12 -0
- package/src/Table/plugins/tree/useTableTreeData-perf.test.tsx +163 -0
- package/src/Table/plugins/tree/useTableTreeData.test.tsx +594 -0
- package/src/Table/plugins/tree/useTableTreeData.tsx +495 -0
- package/src/Table/plugins/tree/useTableTreeState.test.tsx +636 -0
- package/src/Table/plugins/tree/useTableTreeState.tsx +253 -0
- package/src/Table/useBaseTablePlugins.ts +6 -2
- package/src/Table/useTableTreeData.doc.mjs +75 -0
- package/src/Table/useTableTreeState.doc.mjs +98 -0
- package/src/TextArea/TextArea.doc.mjs +1 -1
- package/src/TextInput/TextInput.doc.mjs +1 -1
- package/src/Toolbar/Toolbar.tsx +10 -3
- package/src/TreeList/TreeList.test.tsx +32 -1
- package/src/TreeList/TreeListItem.tsx +19 -10
- package/src/docs-script.test.mjs +18 -13
- package/src/postinstall.test.mjs +71 -0
- package/src/theme/Theme.doc.mjs +4 -4
- package/src/theme/Theme.tsx +1 -1
- package/src/theme/defineTheme.ts +1 -1
- package/src/theme/hct.ts +0 -4
- package/dist/TreeList/treeListItem.markers.stylex.d.ts +0 -16
- package/dist/TreeList/treeListItem.markers.stylex.d.ts.map +0 -1
- package/dist/TreeList/treeListItem.markers.stylex.js +0 -22
- package/src/TreeList/treeListItem.markers.stylex.ts +0 -20
package/src/Table/index.ts
CHANGED
|
@@ -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(
|
|
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={
|
|
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={
|
|
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
|
|
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', {
|
|
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})
|
|
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
|
|
195
|
+
return t('@astryx.table.sort.sortBy', {label});
|
|
195
196
|
}
|
|
196
|
-
|
|
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
|
-
|
|
202
|
+
return t('@astryx.table.sort.sortedByWithPriority', {
|
|
203
|
+
label,
|
|
204
|
+
direction: directionLabel,
|
|
205
|
+
rank,
|
|
206
|
+
total,
|
|
207
|
+
});
|
|
199
208
|
}
|
|
200
|
-
return
|
|
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(
|
|
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
|
+
});
|