@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +1 -0
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +37 -11
  5. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +6 -0
  6. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -0
  7. package/dist/BottomSheet/BottomSheetEdgeTint.js +62 -0
  8. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  9. package/dist/BottomSheet/BottomSheetPanel.js +1 -0
  10. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +1 -0
  11. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  12. package/dist/BottomSheet/BottomSheetSwitcher.js +10 -4
  13. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  14. package/dist/BottomSheet/useSheetGestures.js +23 -5
  15. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  16. package/dist/DateInput/TouchDateField.js +34 -1
  17. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  18. package/dist/MultiSelector/MultiSelector.js +6 -0
  19. package/dist/Selector/Selector.d.ts.map +1 -1
  20. package/dist/Selector/Selector.js +5 -0
  21. package/dist/TabList/Tab.d.ts.map +1 -1
  22. package/dist/TabList/Tab.js +5 -1
  23. package/dist/Table/BaseTable.d.ts.map +1 -1
  24. package/dist/Table/BaseTable.js +4 -1
  25. package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +1 -1
  26. package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +20 -8
  27. package/dist/Table/plugins/selection/useTableSelection.d.ts +16 -0
  28. package/dist/Table/plugins/selection/useTableSelection.d.ts.map +1 -1
  29. package/dist/Table/plugins/selection/useTableSelection.js +19 -5
  30. package/dist/Table/types.d.ts +22 -4
  31. package/dist/Table/types.d.ts.map +1 -1
  32. package/dist/Table/useBaseTablePlugins.d.ts.map +1 -1
  33. package/dist/Table/useBaseTablePlugins.js +5 -0
  34. package/dist/astryx.css +4 -0
  35. package/dist/hooks/useListFocus.d.ts +5 -2
  36. package/dist/hooks/useListFocus.d.ts.map +1 -1
  37. package/dist/hooks/useListFocus.js +12 -6
  38. package/package.json +3 -3
  39. package/src/Avatar/Avatar.doc.mjs +2 -1
  40. package/src/BottomSheet/BottomSheet.tsx +19 -0
  41. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +225 -0
  42. package/src/BottomSheet/BottomSheetEdgeTint.tsx +82 -0
  43. package/src/BottomSheet/BottomSheetPanel.test.tsx +66 -0
  44. package/src/BottomSheet/BottomSheetPanel.tsx +19 -0
  45. package/src/BottomSheet/BottomSheetSwitcher.tsx +13 -0
  46. package/src/BottomSheet/useSheetGestures.test.ts +27 -0
  47. package/src/BottomSheet/useSheetGestures.ts +25 -5
  48. package/src/DateInput/DateInputTouch.test.tsx +36 -0
  49. package/src/DateInput/TouchDateField.tsx +35 -1
  50. package/src/MultiSelector/MultiSelector.test.tsx +21 -0
  51. package/src/MultiSelector/MultiSelector.tsx +10 -1
  52. package/src/Popover/Popover.test.tsx +27 -1
  53. package/src/Selector/Selector.test.tsx +21 -0
  54. package/src/Selector/Selector.tsx +11 -1
  55. package/src/TabList/Tab.tsx +5 -1
  56. package/src/TabList/TabList.test.tsx +21 -4
  57. package/src/Table/BaseTable.tsx +6 -3
  58. package/src/Table/Table.test.tsx +35 -0
  59. package/src/Table/plugins/groupedRows/useTableGroupedRows-perf.test.tsx +112 -0
  60. package/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx +100 -0
  61. package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +17 -8
  62. package/src/Table/plugins/selection/useTableSelection.test.tsx +76 -0
  63. package/src/Table/plugins/selection/useTableSelection.tsx +40 -7
  64. package/src/Table/types.ts +22 -4
  65. package/src/Table/useBaseTablePlugins.ts +5 -0
  66. package/src/Table/useTableGroupedRows.doc.mjs +5 -4
  67. package/src/Table/useTableSelection.doc.mjs +31 -0
  68. package/src/hooks/useListFocus.doc.mjs +2 -2
  69. package/src/hooks/useListFocus.test.tsx +65 -3
  70. package/src/hooks/useListFocus.ts +15 -7
  71. 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 key={`divider-${i}`} xstyle={styles.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 component
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
- elements.push(<Divider key={`divider-${i}`} xstyle={styles.divider} />);
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) {
@@ -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
- 'aria-current': isSelected ? ('page' as const) : undefined,
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
- 'page',
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
- 'page',
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
- 'page',
183
+ 'true',
167
184
  );
168
185
  });
169
186
 
@@ -183,9 +183,12 @@ function TableRowInner<T extends Record<string, unknown>>({
183
183
  );
184
184
 
185
185
  const isDefaultRenderer = !col.renderCell;
186
- const rawContent = isDefaultRenderer
187
- ? defaultCellRenderer(item, col.key)
188
- : (col.renderCell?.(item) ?? null);
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
@@ -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
- // so user cell renderers (`item.name.toUpperCase()`) never throw on a header.
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 arbitrary field access
58
- * from user cell renderers (e.g. `item.name.toUpperCase()`) resolves to `''`
59
- * instead of throwing — BaseTable evaluates `col.renderCell(item)` on every
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(() =>