@astryxdesign/core 0.4.6 → 0.4.7-canary.405f1ee
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 +35 -0
- package/dist/BottomSheet/BottomSheet.d.ts +1 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +37 -11
- package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +6 -0
- package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetEdgeTint.js +62 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetSwitcher.js +10 -4
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +23 -5
- package/dist/DateInput/TouchDateField.d.ts.map +1 -1
- package/dist/DateInput/TouchDateField.js +34 -1
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +6 -0
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +5 -0
- package/dist/TabList/Tab.d.ts.map +1 -1
- package/dist/TabList/Tab.js +5 -1
- package/dist/Table/BaseTable.d.ts.map +1 -1
- package/dist/Table/BaseTable.js +4 -1
- package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
- package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +20 -8
- package/dist/Table/plugins/selection/useTableSelection.d.ts +16 -0
- package/dist/Table/plugins/selection/useTableSelection.d.ts.map +1 -1
- package/dist/Table/plugins/selection/useTableSelection.js +19 -5
- package/dist/Table/types.d.ts +22 -4
- package/dist/Table/types.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
- package/dist/Table/useBaseTablePlugins.js +5 -0
- package/dist/astryx.css +4 -0
- package/dist/hooks/useListFocus.d.ts +5 -2
- package/dist/hooks/useListFocus.d.ts.map +1 -1
- package/dist/hooks/useListFocus.js +12 -6
- package/package.json +3 -3
- package/src/Avatar/Avatar.doc.mjs +2 -1
- package/src/BottomSheet/BottomSheet.tsx +19 -0
- package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +225 -0
- package/src/BottomSheet/BottomSheetEdgeTint.tsx +82 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +66 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +19 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +13 -0
- package/src/BottomSheet/useSheetGestures.test.ts +27 -0
- package/src/BottomSheet/useSheetGestures.ts +25 -5
- package/src/DateInput/DateInputTouch.test.tsx +36 -0
- package/src/DateInput/TouchDateField.tsx +35 -1
- package/src/MultiSelector/MultiSelector.test.tsx +21 -0
- package/src/MultiSelector/MultiSelector.tsx +10 -1
- package/src/Popover/Popover.test.tsx +27 -1
- package/src/Selector/Selector.test.tsx +21 -0
- package/src/Selector/Selector.tsx +11 -1
- package/src/TabList/Tab.tsx +5 -1
- package/src/TabList/TabList.test.tsx +21 -4
- package/src/Table/BaseTable.tsx +6 -3
- package/src/Table/Table.test.tsx +35 -0
- package/src/Table/plugins/groupedRows/useTableGroupedRows-perf.test.tsx +112 -0
- package/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx +100 -0
- package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +17 -8
- package/src/Table/plugins/selection/useTableSelection.test.tsx +76 -0
- package/src/Table/plugins/selection/useTableSelection.tsx +40 -7
- package/src/Table/types.ts +22 -4
- package/src/Table/useBaseTablePlugins.ts +5 -0
- package/src/Table/useTableGroupedRows.doc.mjs +5 -4
- package/src/Table/useTableSelection.doc.mjs +31 -0
- package/src/hooks/useListFocus.doc.mjs +2 -2
- package/src/hooks/useListFocus.test.tsx +65 -3
- package/src/hooks/useListFocus.ts +15 -7
- package/src/theme/MediaTheme.doc.mjs +5 -5
|
@@ -1420,8 +1420,17 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
|
|
|
1420
1420
|
// A standalone divider between groups would orphan itself once its
|
|
1421
1421
|
// neighbors are filtered out, so skip it while searching.
|
|
1422
1422
|
if (!isSearching) {
|
|
1423
|
+
// role="listbox" only permits option/group children; the divider
|
|
1424
|
+
// carries no information the options don't, so it's hidden from
|
|
1425
|
+
// the accessibility tree entirely rather than exposing
|
|
1426
|
+
// role="separator" as a disallowed listbox child (axe
|
|
1427
|
+
// aria-required-children).
|
|
1423
1428
|
elements.push(
|
|
1424
|
-
<Divider
|
|
1429
|
+
<Divider
|
|
1430
|
+
key={`divider-${i}`}
|
|
1431
|
+
aria-hidden="true"
|
|
1432
|
+
xstyle={styles.divider}
|
|
1433
|
+
/>,
|
|
1425
1434
|
);
|
|
1426
1435
|
}
|
|
1427
1436
|
} else if (isSection(option)) {
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file Popover.test.tsx
|
|
5
|
-
* @input Uses vitest, @testing-library/react, Popover
|
|
5
|
+
* @input Uses vitest, @testing-library/react, Popover, Dialog,
|
|
6
|
+
* SegmentedControl
|
|
6
7
|
* @output Unit tests for Popover component behavior
|
|
7
8
|
* @position Testing; validates Popover.tsx implementation
|
|
8
9
|
*
|
|
@@ -14,6 +15,7 @@ import {render, screen, fireEvent} from '@testing-library/react';
|
|
|
14
15
|
import React, {useRef} from 'react';
|
|
15
16
|
import {Popover} from './Popover';
|
|
16
17
|
import {Dialog} from '../Dialog';
|
|
18
|
+
import {SegmentedControl, SegmentedControlItem} from '../SegmentedControl';
|
|
17
19
|
|
|
18
20
|
// Store original matches to restore later
|
|
19
21
|
const originalMatches = HTMLElement.prototype.matches;
|
|
@@ -306,6 +308,30 @@ describe('Popover', () => {
|
|
|
306
308
|
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
307
309
|
});
|
|
308
310
|
|
|
311
|
+
it('dismisses on Escape pressed inside a roving-focus list', () => {
|
|
312
|
+
render(
|
|
313
|
+
<Popover
|
|
314
|
+
content={
|
|
315
|
+
<SegmentedControl value="grid" onChange={() => {}} label="View">
|
|
316
|
+
<SegmentedControlItem value="grid" label="Grid" />
|
|
317
|
+
<SegmentedControlItem value="list" label="List" />
|
|
318
|
+
</SegmentedControl>
|
|
319
|
+
}
|
|
320
|
+
label="Test">
|
|
321
|
+
<button type="button">Open</button>
|
|
322
|
+
</Popover>,
|
|
323
|
+
);
|
|
324
|
+
const trigger = screen.getByRole('button', {name: 'Open'});
|
|
325
|
+
fireEvent.click(trigger);
|
|
326
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
327
|
+
|
|
328
|
+
// From the segment, so the list's own key handler runs first. jsdom has
|
|
329
|
+
// no popover display, so the open content still reads as hidden.
|
|
330
|
+
const segment = screen.getByRole('radio', {name: 'Grid', hidden: true});
|
|
331
|
+
fireEvent.keyDown(segment, {key: 'Escape'});
|
|
332
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
333
|
+
});
|
|
334
|
+
|
|
309
335
|
it('stays open on Escape when hasEscapeDismiss is false', () => {
|
|
310
336
|
render(
|
|
311
337
|
<Popover
|
|
@@ -2484,6 +2484,27 @@ describe('Selector section headings', () => {
|
|
|
2484
2484
|
// visible heading must not announce it a second time.
|
|
2485
2485
|
expect(heading).toHaveAttribute('aria-hidden', 'true');
|
|
2486
2486
|
});
|
|
2487
|
+
|
|
2488
|
+
it('hides a standalone divider from the accessibility tree (#4994)', async () => {
|
|
2489
|
+
const user = userEvent.setup();
|
|
2490
|
+
render(
|
|
2491
|
+
<Selector
|
|
2492
|
+
label="Fruit"
|
|
2493
|
+
options={['Apple', {type: 'divider'}, 'Banana']}
|
|
2494
|
+
value={undefined}
|
|
2495
|
+
onChange={() => {}}
|
|
2496
|
+
/>,
|
|
2497
|
+
);
|
|
2498
|
+
await user.click(screen.getByRole('combobox'));
|
|
2499
|
+
|
|
2500
|
+
// role="listbox" only permits option/group children. The divider still
|
|
2501
|
+
// renders role="separator" (unchanged visual/DOM), but must be excluded
|
|
2502
|
+
// from the accessibility tree so it never reaches the listbox's exposed
|
|
2503
|
+
// children (axe aria-required-children).
|
|
2504
|
+
const divider = document.querySelector('[role="separator"]');
|
|
2505
|
+
expect(divider).toBeTruthy();
|
|
2506
|
+
expect(divider).toHaveAttribute('aria-hidden', 'true');
|
|
2507
|
+
});
|
|
2487
2508
|
});
|
|
2488
2509
|
|
|
2489
2510
|
describe('Selector search focus ring', () => {
|
|
@@ -1344,7 +1344,17 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
1344
1344
|
if (isSearching) {
|
|
1345
1345
|
continue;
|
|
1346
1346
|
}
|
|
1347
|
-
|
|
1347
|
+
// role="listbox" only permits option/group children; the divider
|
|
1348
|
+
// carries no information the options don't, so it's hidden from the
|
|
1349
|
+
// accessibility tree entirely rather than exposing role="separator"
|
|
1350
|
+
// as a disallowed listbox child (axe aria-required-children).
|
|
1351
|
+
elements.push(
|
|
1352
|
+
<Divider
|
|
1353
|
+
key={`divider-${i}`}
|
|
1354
|
+
aria-hidden="true"
|
|
1355
|
+
xstyle={styles.divider}
|
|
1356
|
+
/>,
|
|
1357
|
+
);
|
|
1348
1358
|
} else if (isSection(option)) {
|
|
1349
1359
|
const sectionItems: ReactNode[] = [];
|
|
1350
1360
|
for (const opt of option.options) {
|
package/src/TabList/Tab.tsx
CHANGED
|
@@ -260,7 +260,11 @@ export function Tab({
|
|
|
260
260
|
...(isLabelHidden ? {'aria-label': label} : {}),
|
|
261
261
|
[EDGE_COMP_ATTR]: '',
|
|
262
262
|
'data-tab-value': value,
|
|
263
|
-
|
|
263
|
+
// Generic `true` ("the current item within a set"), not `page`: the strip
|
|
264
|
+
// switches views in place at least as often as it navigates, and claiming
|
|
265
|
+
// "current page" when no page changed is a false statement to a screen
|
|
266
|
+
// reader. Stays truthful for the `href` case too, just less specific.
|
|
267
|
+
'aria-current': isSelected ? ('true' as const) : undefined,
|
|
264
268
|
// Roving tabindex: the tab strip is a single Tab stop. The selected tab is
|
|
265
269
|
// the tabbable one; the rest are reachable via arrow keys (handled by
|
|
266
270
|
// TabList's onKeyDown). When no tab is selected, TabList's repair effect
|
|
@@ -106,7 +106,7 @@ describe('TabList', () => {
|
|
|
106
106
|
);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
it('marks selected tab with aria-current', () => {
|
|
109
|
+
it('marks selected tab with a generic aria-current, not "page"', () => {
|
|
110
110
|
render(
|
|
111
111
|
<TabList value="home" onChange={() => {}}>
|
|
112
112
|
<Tab value="home" label="Home" />
|
|
@@ -116,13 +116,30 @@ describe('TabList', () => {
|
|
|
116
116
|
|
|
117
117
|
expect(screen.getByRole('button', {name: 'Home'})).toHaveAttribute(
|
|
118
118
|
'aria-current',
|
|
119
|
-
'
|
|
119
|
+
'true',
|
|
120
120
|
);
|
|
121
121
|
expect(screen.getByRole('button', {name: 'Settings'})).not.toHaveAttribute(
|
|
122
122
|
'aria-current',
|
|
123
123
|
);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
it('marks a selected link tab with the same generic aria-current', () => {
|
|
127
|
+
render(
|
|
128
|
+
<TabList value="home" onChange={() => {}}>
|
|
129
|
+
<Tab value="home" label="Home" href="/home" />
|
|
130
|
+
<Tab value="settings" label="Settings" href="/settings" />
|
|
131
|
+
</TabList>,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(screen.getByRole('link', {name: 'Home'})).toHaveAttribute(
|
|
135
|
+
'aria-current',
|
|
136
|
+
'true',
|
|
137
|
+
);
|
|
138
|
+
expect(screen.getByRole('link', {name: 'Settings'})).not.toHaveAttribute(
|
|
139
|
+
'aria-current',
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
126
143
|
it('calls onChange when a tab is clicked', async () => {
|
|
127
144
|
const user = userEvent.setup();
|
|
128
145
|
const handleChange = vi.fn();
|
|
@@ -148,7 +165,7 @@ describe('TabList', () => {
|
|
|
148
165
|
|
|
149
166
|
expect(screen.getByRole('button', {name: 'Home'})).toHaveAttribute(
|
|
150
167
|
'aria-current',
|
|
151
|
-
'
|
|
168
|
+
'true',
|
|
152
169
|
);
|
|
153
170
|
|
|
154
171
|
rerender(
|
|
@@ -163,7 +180,7 @@ describe('TabList', () => {
|
|
|
163
180
|
);
|
|
164
181
|
expect(screen.getByRole('button', {name: 'Settings'})).toHaveAttribute(
|
|
165
182
|
'aria-current',
|
|
166
|
-
'
|
|
183
|
+
'true',
|
|
167
184
|
);
|
|
168
185
|
});
|
|
169
186
|
|
package/src/Table/BaseTable.tsx
CHANGED
|
@@ -183,9 +183,12 @@ function TableRowInner<T extends Record<string, unknown>>({
|
|
|
183
183
|
);
|
|
184
184
|
|
|
185
185
|
const isDefaultRenderer = !col.renderCell;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
let rawContent: ReactNode = null;
|
|
187
|
+
if (!cellRenderProps.isContentSuppressed) {
|
|
188
|
+
rawContent = isDefaultRenderer
|
|
189
|
+
? defaultCellRenderer(item, col.key)
|
|
190
|
+
: (col.renderCell?.(item) ?? null);
|
|
191
|
+
}
|
|
189
192
|
|
|
190
193
|
// In truncate mode, wrap default-rendered string content in
|
|
191
194
|
// <Text maxLines={1}> for smart tooltips that only appear
|
package/src/Table/Table.test.tsx
CHANGED
|
@@ -602,6 +602,41 @@ describe('BaseTable', () => {
|
|
|
602
602
|
expect(calls[0]).toEqual({col: 'name', name: 'Alice'});
|
|
603
603
|
});
|
|
604
604
|
|
|
605
|
+
it('isContentSuppressed renders an empty cell and never calls the column renderer', () => {
|
|
606
|
+
const rendered: string[] = [];
|
|
607
|
+
const withRenderer: TableColumn<User>[] = [
|
|
608
|
+
{
|
|
609
|
+
key: 'name',
|
|
610
|
+
header: 'Name',
|
|
611
|
+
renderCell: item => {
|
|
612
|
+
rendered.push(item.name);
|
|
613
|
+
return <b>{item.name}</b>;
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
];
|
|
617
|
+
const plugin: TablePlugin<User> = {
|
|
618
|
+
transformBodyCell: (props, _column, item) =>
|
|
619
|
+
item.name === 'Bob' ? {...props, isContentSuppressed: true} : props,
|
|
620
|
+
};
|
|
621
|
+
render(
|
|
622
|
+
<BaseTable data={users} columns={withRenderer} plugins={[plugin]} />,
|
|
623
|
+
);
|
|
624
|
+
expect(rendered).toEqual(['Alice', 'Charlie']);
|
|
625
|
+
const cells = screen.getAllByRole('cell');
|
|
626
|
+
expect(cells[1]).toBeEmptyDOMElement();
|
|
627
|
+
expect(cells[0]).toHaveTextContent('Alice');
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('isContentSuppressed also suppresses the default renderer', () => {
|
|
631
|
+
const plugin: TablePlugin<User> = {
|
|
632
|
+
transformBodyCell: props => ({...props, isContentSuppressed: true}),
|
|
633
|
+
};
|
|
634
|
+
render(<BaseTable data={users} columns={columns} plugins={[plugin]} />);
|
|
635
|
+
for (const cell of screen.getAllByRole('cell')) {
|
|
636
|
+
expect(cell).toBeEmptyDOMElement();
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
605
640
|
it('composes multiple plugins sequentially', () => {
|
|
606
641
|
const plugin1: TablePlugin<User> = {
|
|
607
642
|
transformTable: props => ({
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file useTableGroupedRows-perf.test.tsx
|
|
5
|
+
* @input Table, useTableGroupedRows, React testing utilities
|
|
6
|
+
* @output Performance tests for grouped-rows render behavior
|
|
7
|
+
* @position Test file; validates that grouping costs no extra row renders
|
|
8
|
+
*
|
|
9
|
+
* The plugin keeps group headers out of cell renderers per cell, at render
|
|
10
|
+
* time. Doing it by rewriting the columns instead would hand BaseTable a new
|
|
11
|
+
* column object on every render, and its element-by-element check on the
|
|
12
|
+
* resolved column array is what stops every row re-rendering.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {describe, it, expect} from 'vitest';
|
|
16
|
+
import {render, screen, act} from '@testing-library/react';
|
|
17
|
+
import {useCallback, useMemo, useState} from 'react';
|
|
18
|
+
import userEvent from '@testing-library/user-event';
|
|
19
|
+
import {Table} from '../../Table';
|
|
20
|
+
import type {TableColumn} from '../../types';
|
|
21
|
+
import {useTableGroupedRows} from './useTableGroupedRows';
|
|
22
|
+
|
|
23
|
+
interface Person extends Record<string, unknown> {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
team: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const people: Person[] = [
|
|
30
|
+
{id: 'a', name: 'Alice', team: 'Core'},
|
|
31
|
+
{id: 'b', name: 'Bob', team: 'Core'},
|
|
32
|
+
{id: 'c', name: 'Carol', team: 'Infra'},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const EMPTY = new Set<string>();
|
|
36
|
+
|
|
37
|
+
function GroupedRenderCountTable({
|
|
38
|
+
renderCounts,
|
|
39
|
+
}: {
|
|
40
|
+
renderCounts: Record<string, number>;
|
|
41
|
+
}) {
|
|
42
|
+
const [tick, setTick] = useState(0);
|
|
43
|
+
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(EMPTY);
|
|
44
|
+
|
|
45
|
+
const columns = useMemo<TableColumn<Person>[]>(
|
|
46
|
+
() => [
|
|
47
|
+
{
|
|
48
|
+
key: 'name',
|
|
49
|
+
header: 'Name',
|
|
50
|
+
renderCell: (item: Person) => {
|
|
51
|
+
renderCounts[item.id] = (renderCounts[item.id] ?? 0) + 1;
|
|
52
|
+
return item.name;
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
[renderCounts],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const grouped = useTableGroupedRows<Person>({
|
|
60
|
+
data: people,
|
|
61
|
+
groupBy: useCallback((p: Person) => p.team, []),
|
|
62
|
+
collapsedGroups,
|
|
63
|
+
onToggleGroup: useCallback(
|
|
64
|
+
(key: string) =>
|
|
65
|
+
setCollapsedGroups(prev => {
|
|
66
|
+
const next = new Set(prev);
|
|
67
|
+
if (!next.delete(key)) {
|
|
68
|
+
next.add(key);
|
|
69
|
+
}
|
|
70
|
+
return next;
|
|
71
|
+
}),
|
|
72
|
+
[],
|
|
73
|
+
),
|
|
74
|
+
getRowKey: useCallback((p: Person) => p.id, []),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
<button type="button" onClick={() => setTick(t => t + 1)}>
|
|
80
|
+
bump {tick}
|
|
81
|
+
</button>
|
|
82
|
+
<Table
|
|
83
|
+
data={grouped.data}
|
|
84
|
+
columns={columns}
|
|
85
|
+
idKey={grouped.idKey}
|
|
86
|
+
plugins={{grouped: grouped.plugin}}
|
|
87
|
+
/>
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('Grouped rows render performance', () => {
|
|
93
|
+
it('renders each real row once and no group header at all', () => {
|
|
94
|
+
const renderCounts: Record<string, number> = {};
|
|
95
|
+
render(<GroupedRenderCountTable renderCounts={renderCounts} />);
|
|
96
|
+
expect(renderCounts).toEqual({a: 1, b: 1, c: 1});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('a re-render of the surrounding component re-renders no rows', async () => {
|
|
100
|
+
const user = userEvent.setup();
|
|
101
|
+
const renderCounts: Record<string, number> = {};
|
|
102
|
+
render(<GroupedRenderCountTable renderCounts={renderCounts} />);
|
|
103
|
+
const before = {...renderCounts};
|
|
104
|
+
|
|
105
|
+
await act(async () => {
|
|
106
|
+
await user.click(screen.getByRole('button', {name: /bump/}));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(screen.getByRole('button', {name: 'bump 1'})).toBeInTheDocument();
|
|
110
|
+
expect(renderCounts).toEqual(before);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -5,6 +5,7 @@ import {render, screen, fireEvent, within} from '@testing-library/react';
|
|
|
5
5
|
import {useState, useCallback} from 'react';
|
|
6
6
|
import {Table} from '../../Table';
|
|
7
7
|
import type {TableColumn} from '../../types';
|
|
8
|
+
import {useTableRowStatus, type TableRowStatus} from '../rowStatus';
|
|
8
9
|
import {useTableGroupedRows} from './useTableGroupedRows';
|
|
9
10
|
|
|
10
11
|
interface Person extends Record<string, unknown> {
|
|
@@ -175,6 +176,105 @@ describe('useTableGroupedRows', () => {
|
|
|
175
176
|
).not.toBeInTheDocument();
|
|
176
177
|
});
|
|
177
178
|
|
|
179
|
+
it('never runs a column renderCell against a group header row', () => {
|
|
180
|
+
// The renderer that breaks: it keys a lookup off a field rather than
|
|
181
|
+
// printing it, so the header Proxy answering `''` is no better than
|
|
182
|
+
// `undefined`.
|
|
183
|
+
const TEAM_META: Record<string, {dot: string}> = {
|
|
184
|
+
Core: {dot: 'green'},
|
|
185
|
+
Infra: {dot: 'blue'},
|
|
186
|
+
};
|
|
187
|
+
const seen: Person[] = [];
|
|
188
|
+
const lookupColumns: TableColumn<Person>[] = [
|
|
189
|
+
{
|
|
190
|
+
key: 'team',
|
|
191
|
+
header: 'Team',
|
|
192
|
+
renderCell: item => {
|
|
193
|
+
seen.push(item);
|
|
194
|
+
return <span>{TEAM_META[item.team].dot}</span>;
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
function LookupHarness() {
|
|
200
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(EMPTY);
|
|
201
|
+
const grouped = useTableGroupedRows<Person>({
|
|
202
|
+
data: people,
|
|
203
|
+
groupBy: p => p.team,
|
|
204
|
+
collapsedGroups: collapsed,
|
|
205
|
+
onToggleGroup: k => setCollapsed(new Set([k])),
|
|
206
|
+
getRowKey: p => p.id,
|
|
207
|
+
});
|
|
208
|
+
return (
|
|
209
|
+
<Table
|
|
210
|
+
data={grouped.data}
|
|
211
|
+
columns={lookupColumns}
|
|
212
|
+
idKey={grouped.idKey}
|
|
213
|
+
plugins={{grouped: grouped.plugin}}
|
|
214
|
+
/>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
expect(() => render(<LookupHarness />)).not.toThrow();
|
|
219
|
+
expect(seen.map(p => p.name)).toEqual(['Alice', 'Bob', 'Carol']);
|
|
220
|
+
expect(screen.getByText('Core')).toBeInTheDocument();
|
|
221
|
+
expect(screen.getByText('Infra')).toBeInTheDocument();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe.each([
|
|
225
|
+
['grouped first', true],
|
|
226
|
+
['rowStatus first', false],
|
|
227
|
+
])(
|
|
228
|
+
'with a column contributed by another plugin (%s)',
|
|
229
|
+
(_label, groupedFirst) => {
|
|
230
|
+
// `grouped` and `rowStatus` are both absent from PLUGIN_ORDER, so the
|
|
231
|
+
// record's insertion order is the pipeline order. Whichever way round
|
|
232
|
+
// the consumer types it, the status column's renderCell must not see a
|
|
233
|
+
// header row.
|
|
234
|
+
const STATUS_BY_TEAM: Record<string, TableRowStatus> = {
|
|
235
|
+
Core: {color: 'success', label: 'Healthy'},
|
|
236
|
+
Infra: {color: 'error', label: 'Paging'},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
function StatusHarness() {
|
|
240
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(EMPTY);
|
|
241
|
+
const grouped = useTableGroupedRows<Person>({
|
|
242
|
+
data: people,
|
|
243
|
+
groupBy: p => p.team,
|
|
244
|
+
collapsedGroups: collapsed,
|
|
245
|
+
onToggleGroup: k => setCollapsed(new Set([k])),
|
|
246
|
+
getRowKey: p => p.id,
|
|
247
|
+
});
|
|
248
|
+
const rowStatus = useTableRowStatus<Person>({
|
|
249
|
+
getStatus: useCallback((item: Person) => {
|
|
250
|
+
const {color, label} = STATUS_BY_TEAM[item.team];
|
|
251
|
+
return {color, label};
|
|
252
|
+
}, []),
|
|
253
|
+
});
|
|
254
|
+
return (
|
|
255
|
+
<Table
|
|
256
|
+
data={grouped.data}
|
|
257
|
+
columns={columns}
|
|
258
|
+
idKey={grouped.idKey}
|
|
259
|
+
plugins={
|
|
260
|
+
groupedFirst
|
|
261
|
+
? {grouped: grouped.plugin, rowStatus}
|
|
262
|
+
: {rowStatus, grouped: grouped.plugin}
|
|
263
|
+
}
|
|
264
|
+
/>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
it('renders the group headers without throwing', () => {
|
|
269
|
+
expect(() => render(<StatusHarness />)).not.toThrow();
|
|
270
|
+
expect(screen.getByText('Core')).toBeInTheDocument();
|
|
271
|
+
expect(screen.getByText('Infra')).toBeInTheDocument();
|
|
272
|
+
// One indicator per real row, none on the two headers.
|
|
273
|
+
expect(screen.getAllByRole('img')).toHaveLength(3);
|
|
274
|
+
});
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
|
|
178
278
|
it('keeps a group collapsed across a data change (state keyed by group)', () => {
|
|
179
279
|
function ChangingHarness() {
|
|
180
280
|
const [collapsed, setCollapsed] = useState<Set<string>>(
|
|
@@ -41,8 +41,11 @@ function isGroupHeader(item: unknown): item is GroupHeader {
|
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// Proxy handler: any field access beyond the marker fields resolves to `''
|
|
45
|
-
//
|
|
44
|
+
// Proxy handler: any field access beyond the marker fields resolves to `''`,
|
|
45
|
+
// which keeps anything that reads a header's data — the sortable and filtering
|
|
46
|
+
// plugins, a consumer's own row handler — off `undefined`. Cell renderers are
|
|
47
|
+
// handled at render time instead (see `transformBodyCell` below); the Proxy
|
|
48
|
+
// never protected them, because `''` fails a lookup exactly as `undefined` does.
|
|
46
49
|
const HEADER_PROXY_HANDLER: ProxyHandler<Record<string | symbol, unknown>> = {
|
|
47
50
|
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Proxy get trap, not a promise-returning fn
|
|
48
51
|
get(t: Record<string | symbol, unknown>, prop: string | symbol): unknown {
|
|
@@ -54,12 +57,9 @@ const HEADER_PROXY_HANDLER: ProxyHandler<Record<string | symbol, unknown>> = {
|
|
|
54
57
|
};
|
|
55
58
|
|
|
56
59
|
/**
|
|
57
|
-
* Build a synthetic header row wrapped in a Proxy so
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* row (including synthetic headers) before `transformBodyRow` can replace the
|
|
61
|
-
* row's cells. `transformBodyRow` then discards those cells and renders a
|
|
62
|
-
* single full-width header cell.
|
|
60
|
+
* Build a synthetic header row wrapped in a Proxy so field access resolves to
|
|
61
|
+
* `''` instead of `undefined`. `transformBodyRow` then discards the row's
|
|
62
|
+
* cells and renders a single full-width header cell.
|
|
63
63
|
*/
|
|
64
64
|
function makeHeader<T extends Record<string, unknown>>(
|
|
65
65
|
groupKey: string,
|
|
@@ -301,6 +301,15 @@ export function useTableGroupedRows<T extends Record<string, unknown>>(
|
|
|
301
301
|
|
|
302
302
|
const plugin = useMemo(
|
|
303
303
|
(): TablePlugin<T> => ({
|
|
304
|
+
// A header row is not one of the caller's rows, so running a cell
|
|
305
|
+
// renderer against it can only misread it or throw — and the cells are
|
|
306
|
+
// discarded by `transformBodyRow` below regardless.
|
|
307
|
+
transformBodyCell(props, _column, item) {
|
|
308
|
+
if (!isGroupHeader(item)) {
|
|
309
|
+
return props;
|
|
310
|
+
}
|
|
311
|
+
return {...props, isContentSuppressed: true};
|
|
312
|
+
},
|
|
304
313
|
// Replace a header row's pre-rendered cells with one full-width cell.
|
|
305
314
|
transformBodyRow(props, item) {
|
|
306
315
|
if (!isGroupHeader(item)) {
|
|
@@ -13,6 +13,7 @@ import {act, render, renderHook, screen, within} from '@testing-library/react';
|
|
|
13
13
|
import userEvent from '@testing-library/user-event';
|
|
14
14
|
import {Table} from '../../Table';
|
|
15
15
|
import {useTableSelection} from './useTableSelection';
|
|
16
|
+
import {colorVars} from '../../../theme/tokens.stylex';
|
|
16
17
|
import type {BodyRowRenderProps, TableColumn} from '../../types';
|
|
17
18
|
|
|
18
19
|
// =============================================================================
|
|
@@ -41,10 +42,12 @@ function SelectionTable({
|
|
|
41
42
|
getIsItemSelectable,
|
|
42
43
|
getIsItemEnabled,
|
|
43
44
|
getRowLabel,
|
|
45
|
+
hasRowHighlight,
|
|
44
46
|
}: {
|
|
45
47
|
getIsItemSelectable?: (item: SelectableUser) => boolean;
|
|
46
48
|
getIsItemEnabled?: (item: SelectableUser) => boolean;
|
|
47
49
|
getRowLabel?: (item: SelectableUser) => string;
|
|
50
|
+
hasRowHighlight?: boolean;
|
|
48
51
|
}) {
|
|
49
52
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
|
50
53
|
|
|
@@ -78,6 +81,7 @@ function SelectionTable({
|
|
|
78
81
|
getIsItemSelectable,
|
|
79
82
|
getIsItemEnabled,
|
|
80
83
|
getRowLabel,
|
|
84
|
+
hasRowHighlight,
|
|
81
85
|
});
|
|
82
86
|
|
|
83
87
|
return (
|
|
@@ -211,6 +215,78 @@ describe('useTableSelection', () => {
|
|
|
211
215
|
expect(headers).toHaveLength(3);
|
|
212
216
|
});
|
|
213
217
|
|
|
218
|
+
describe('hasRowHighlight', () => {
|
|
219
|
+
const selectedBgColor = colorVars['--color-accent-muted'];
|
|
220
|
+
|
|
221
|
+
it('paints a checked row with the accent wash by default', async () => {
|
|
222
|
+
const user = userEvent.setup();
|
|
223
|
+
render(<SelectionTable />);
|
|
224
|
+
|
|
225
|
+
await user.click(screen.getAllByLabelText('Select row')[0]);
|
|
226
|
+
|
|
227
|
+
const row = screen.getAllByRole('row')[1];
|
|
228
|
+
expect(row.style.backgroundColor).toBe(selectedBgColor);
|
|
229
|
+
expect(row).toHaveAttribute('aria-selected', 'true');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('leaves the row background alone when hasRowHighlight is false', async () => {
|
|
233
|
+
const user = userEvent.setup();
|
|
234
|
+
render(<SelectionTable hasRowHighlight={false} />);
|
|
235
|
+
|
|
236
|
+
await user.click(screen.getAllByLabelText('Select row')[0]);
|
|
237
|
+
|
|
238
|
+
const row = screen.getAllByRole('row')[1];
|
|
239
|
+
expect(row.style.backgroundColor).toBe('');
|
|
240
|
+
// The wash is opt-out; the semantics are not.
|
|
241
|
+
expect(row).toHaveAttribute('aria-selected', 'true');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('clears an already-painted row when the flag flips to false', async () => {
|
|
245
|
+
const user = userEvent.setup();
|
|
246
|
+
const {rerender} = render(<SelectionTable />);
|
|
247
|
+
|
|
248
|
+
await user.click(screen.getAllByLabelText('Select row')[0]);
|
|
249
|
+
expect(screen.getAllByRole('row')[1].style.backgroundColor).toBe(
|
|
250
|
+
selectedBgColor,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
rerender(<SelectionTable hasRowHighlight={false} />);
|
|
254
|
+
|
|
255
|
+
const row = screen.getAllByRole('row')[1];
|
|
256
|
+
expect(row.style.backgroundColor).toBe('');
|
|
257
|
+
expect(row).toHaveAttribute('aria-selected', 'true');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('repaints an already-checked row when the flag flips back to true', async () => {
|
|
261
|
+
const user = userEvent.setup();
|
|
262
|
+
const {rerender} = render(<SelectionTable hasRowHighlight={false} />);
|
|
263
|
+
|
|
264
|
+
await user.click(screen.getAllByLabelText('Select row')[0]);
|
|
265
|
+
rerender(<SelectionTable hasRowHighlight />);
|
|
266
|
+
|
|
267
|
+
expect(screen.getAllByRole('row')[1].style.backgroundColor).toBe(
|
|
268
|
+
selectedBgColor,
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('never paints unchecked rows in either mode', async () => {
|
|
273
|
+
const user = userEvent.setup();
|
|
274
|
+
const {rerender} = render(<SelectionTable />);
|
|
275
|
+
|
|
276
|
+
await user.click(screen.getAllByLabelText('Select row')[0]);
|
|
277
|
+
expect(screen.getAllByRole('row')[2].style.backgroundColor).toBe('');
|
|
278
|
+
expect(screen.getAllByRole('row')[2]).not.toHaveAttribute(
|
|
279
|
+
'aria-selected',
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
rerender(<SelectionTable hasRowHighlight={false} />);
|
|
283
|
+
expect(screen.getAllByRole('row')[2].style.backgroundColor).toBe('');
|
|
284
|
+
expect(screen.getAllByRole('row')[2]).not.toHaveAttribute(
|
|
285
|
+
'aria-selected',
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
214
290
|
it('unsubscribes detached row refs instead of accumulating listeners', () => {
|
|
215
291
|
const getIsItemSelected = vi.fn(() => false);
|
|
216
292
|
const {result, rerender} = renderHook(() =>
|