@astryxdesign/core 0.1.5-canary.efe7cc3 → 0.1.5
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/dist/AlertDialog/useImperativeAlertDialog.d.ts +2 -0
- package/dist/AlertDialog/useImperativeAlertDialog.d.ts.map +1 -1
- package/dist/AlertDialog/useImperativeAlertDialog.js +2 -0
- package/dist/Lightbox/useLightbox.d.ts +1 -0
- package/dist/Lightbox/useLightbox.d.ts.map +1 -1
- package/dist/Lightbox/useLightbox.js +1 -0
- package/dist/Link/Link.d.ts +2 -0
- package/dist/Link/Link.d.ts.map +1 -1
- package/dist/Link/Link.js +2 -0
- package/dist/Overlay/useOverlay.d.ts +3 -0
- package/dist/Overlay/useOverlay.d.ts.map +1 -1
- package/dist/Overlay/useOverlay.js +3 -0
- package/dist/Table/index.d.ts +0 -2
- package/dist/Table/index.d.ts.map +1 -1
- package/dist/Table/index.js +0 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +1 -1
- package/dist/VisuallyHidden/VisuallyHidden.d.ts +3 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
- package/dist/VisuallyHidden/VisuallyHidden.js +3 -0
- package/dist/astryx.umd.js +53 -53
- package/dist/astryx.umd.js.map +4 -4
- package/package.json +1 -1
- package/src/AlertDialog/useImperativeAlertDialog.tsx +2 -0
- package/src/Lightbox/useLightbox.tsx +1 -0
- package/src/Link/Link.tsx +2 -0
- package/src/Overlay/useOverlay.tsx +3 -0
- package/src/Table/index.ts +0 -5
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +1 -1
- package/src/VisuallyHidden/VisuallyHidden.tsx +3 -0
- package/dist/Table/plugins/groupedRows/index.d.ts +0 -3
- package/dist/Table/plugins/groupedRows/index.d.ts.map +0 -1
- package/dist/Table/plugins/groupedRows/index.js +0 -3
- package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts +0 -78
- package/dist/Table/plugins/groupedRows/useTableGroupedRows.d.ts.map +0 -1
- package/dist/Table/plugins/groupedRows/useTableGroupedRows.js +0 -250
- package/src/Table/plugins/groupedRows/index.ts +0 -7
- package/src/Table/plugins/groupedRows/useTableGroupedRows.test.tsx +0 -231
- package/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +0 -349
- package/src/Table/useTableGroupedRows.doc.mjs +0 -71
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
import {describe, it, expect} from 'vitest';
|
|
4
|
-
import {render, screen, fireEvent, within} from '@testing-library/react';
|
|
5
|
-
import {useState, useCallback} from 'react';
|
|
6
|
-
import {Table} from '../../Table';
|
|
7
|
-
import type {TableColumn} from '../../types';
|
|
8
|
-
import {useTableGroupedRows} from './useTableGroupedRows';
|
|
9
|
-
|
|
10
|
-
interface Person extends Record<string, unknown> {
|
|
11
|
-
id: string;
|
|
12
|
-
name: string;
|
|
13
|
-
team: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const people: Person[] = [
|
|
17
|
-
{id: 'a', name: 'Alice', team: 'Core'},
|
|
18
|
-
{id: 'b', name: 'Bob', team: 'Core'},
|
|
19
|
-
{id: 'c', name: 'Carol', team: 'Infra'},
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
const columns: TableColumn<Person>[] = [{key: 'name', header: 'Name'}];
|
|
23
|
-
|
|
24
|
-
const EMPTY = new Set<string>();
|
|
25
|
-
|
|
26
|
-
function Harness({
|
|
27
|
-
rows = people,
|
|
28
|
-
initialCollapsed = EMPTY,
|
|
29
|
-
renderGroupHeader,
|
|
30
|
-
}: {
|
|
31
|
-
rows?: Person[];
|
|
32
|
-
initialCollapsed?: Set<string>;
|
|
33
|
-
renderGroupHeader?: (
|
|
34
|
-
groupKey: string,
|
|
35
|
-
count: number,
|
|
36
|
-
collapsed: boolean,
|
|
37
|
-
) => React.ReactNode;
|
|
38
|
-
}) {
|
|
39
|
-
const [collapsedGroups, setCollapsed] = useState(initialCollapsed);
|
|
40
|
-
const onToggleGroup = useCallback((key: string) => {
|
|
41
|
-
setCollapsed(prev => {
|
|
42
|
-
const next = new Set(prev);
|
|
43
|
-
if (next.has(key)) {
|
|
44
|
-
next.delete(key);
|
|
45
|
-
} else {
|
|
46
|
-
next.add(key);
|
|
47
|
-
}
|
|
48
|
-
return next;
|
|
49
|
-
});
|
|
50
|
-
}, []);
|
|
51
|
-
const grouped = useTableGroupedRows<Person>({
|
|
52
|
-
data: rows,
|
|
53
|
-
groupBy: p => p.team,
|
|
54
|
-
collapsedGroups,
|
|
55
|
-
onToggleGroup,
|
|
56
|
-
getRowKey: p => p.id,
|
|
57
|
-
renderGroupHeader,
|
|
58
|
-
});
|
|
59
|
-
return (
|
|
60
|
-
<Table
|
|
61
|
-
data={grouped.data}
|
|
62
|
-
columns={columns}
|
|
63
|
-
idKey={grouped.idKey}
|
|
64
|
-
plugins={{grouped: grouped.plugin}}
|
|
65
|
-
/>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
describe('useTableGroupedRows', () => {
|
|
70
|
-
it('renders a header row per group with label and count', () => {
|
|
71
|
-
render(<Harness />);
|
|
72
|
-
// Two groups: Core (2), Infra (1).
|
|
73
|
-
expect(screen.getByText('Core')).toBeInTheDocument();
|
|
74
|
-
expect(screen.getByText('(2)')).toBeInTheDocument();
|
|
75
|
-
expect(screen.getByText('Infra')).toBeInTheDocument();
|
|
76
|
-
expect(screen.getByText('(1)')).toBeInTheDocument();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('shows group members when expanded', () => {
|
|
80
|
-
render(<Harness />);
|
|
81
|
-
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
82
|
-
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
83
|
-
expect(screen.getByText('Carol')).toBeInTheDocument();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('hides members of a collapsed group but keeps the header', () => {
|
|
87
|
-
render(<Harness initialCollapsed={new Set(['Core'])} />);
|
|
88
|
-
expect(screen.getByText('Core')).toBeInTheDocument();
|
|
89
|
-
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
|
|
90
|
-
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
|
|
91
|
-
// Other group unaffected.
|
|
92
|
-
expect(screen.getByText('Carol')).toBeInTheDocument();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('toggles a group via its chevron and back again', () => {
|
|
96
|
-
render(<Harness />);
|
|
97
|
-
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
98
|
-
// The standalone chevron button toggles the group; its accessible name is
|
|
99
|
-
// qualified with the group key.
|
|
100
|
-
fireEvent.click(screen.getByRole('button', {name: 'Collapse group Core'}));
|
|
101
|
-
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
|
|
102
|
-
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
|
|
103
|
-
expect(screen.getByText('Core')).toBeInTheDocument();
|
|
104
|
-
expect(screen.getByText('Carol')).toBeInTheDocument();
|
|
105
|
-
// Toggle back: the Core chevron now says "Expand group Core".
|
|
106
|
-
fireEvent.click(screen.getByRole('button', {name: 'Expand group Core'}));
|
|
107
|
-
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
108
|
-
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('exposes each group toggle as a named, keyboard-operable button', () => {
|
|
112
|
-
render(<Harness />);
|
|
113
|
-
// Native <button>: focusable and operable without a custom key handler.
|
|
114
|
-
const coreToggle = screen.getByRole('button', {
|
|
115
|
-
name: 'Collapse group Core',
|
|
116
|
-
});
|
|
117
|
-
expect(coreToggle.tagName).toBe('BUTTON');
|
|
118
|
-
expect(coreToggle).toHaveAttribute('aria-expanded', 'true');
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('sets aria-expanded on the header row reflecting collapse state', () => {
|
|
122
|
-
render(<Harness initialCollapsed={new Set(['Core'])} />);
|
|
123
|
-
const rows = screen.getAllByRole('row');
|
|
124
|
-
// rows[1] = Core header (collapsed), rows[2] = Infra header (expanded).
|
|
125
|
-
expect(rows[1]).toHaveAttribute('aria-expanded', 'false');
|
|
126
|
-
expect(rows[2]).toHaveAttribute('aria-expanded', 'true');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('respects groupOrder in the flattened data', () => {
|
|
130
|
-
function OrderedHarness() {
|
|
131
|
-
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
132
|
-
const grouped = useTableGroupedRows<Person>({
|
|
133
|
-
data: people,
|
|
134
|
-
groupBy: p => p.team,
|
|
135
|
-
collapsedGroups: collapsed,
|
|
136
|
-
onToggleGroup: k => setCollapsed(new Set([k])),
|
|
137
|
-
getRowKey: p => p.id,
|
|
138
|
-
groupOrder: ['Infra', 'Core'],
|
|
139
|
-
});
|
|
140
|
-
expect(grouped.data.length).toBeGreaterThan(0);
|
|
141
|
-
return (
|
|
142
|
-
<Table
|
|
143
|
-
data={grouped.data}
|
|
144
|
-
columns={columns}
|
|
145
|
-
idKey={grouped.idKey}
|
|
146
|
-
plugins={{grouped: grouped.plugin}}
|
|
147
|
-
/>
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
render(<OrderedHarness />);
|
|
151
|
-
const rows = screen.getAllByRole('row');
|
|
152
|
-
// rows[0] = column header; rows[1] = first group header (Infra).
|
|
153
|
-
expect(within(rows[1]).getByText('Infra')).toBeInTheDocument();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('renders custom group header content via renderGroupHeader', () => {
|
|
157
|
-
render(
|
|
158
|
-
<Harness
|
|
159
|
-
renderGroupHeader={(key, count, collapsed) => (
|
|
160
|
-
<span>{`${key}::${count}::${collapsed ? 'closed' : 'open'}`}</span>
|
|
161
|
-
)}
|
|
162
|
-
/>,
|
|
163
|
-
);
|
|
164
|
-
expect(screen.getByText('Core::2::open')).toBeInTheDocument();
|
|
165
|
-
expect(screen.getByText('Infra::1::open')).toBeInTheDocument();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it('renders nothing (no group headers) for empty data', () => {
|
|
169
|
-
render(<Harness rows={[]} />);
|
|
170
|
-
// Column header row still renders; no group header rows.
|
|
171
|
-
expect(screen.queryByText('Core')).not.toBeInTheDocument();
|
|
172
|
-
expect(screen.queryByText('Infra')).not.toBeInTheDocument();
|
|
173
|
-
expect(
|
|
174
|
-
screen.queryByRole('button', {name: /group/i}),
|
|
175
|
-
).not.toBeInTheDocument();
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it('keeps a group collapsed across a data change (state keyed by group)', () => {
|
|
179
|
-
function ChangingHarness() {
|
|
180
|
-
const [collapsed, setCollapsed] = useState<Set<string>>(
|
|
181
|
-
new Set(['Core']),
|
|
182
|
-
);
|
|
183
|
-
const [rows, setRows] = useState(people);
|
|
184
|
-
const onToggleGroup = useCallback((key: string) => {
|
|
185
|
-
setCollapsed(prev => {
|
|
186
|
-
const next = new Set(prev);
|
|
187
|
-
if (next.has(key)) {
|
|
188
|
-
next.delete(key);
|
|
189
|
-
} else {
|
|
190
|
-
next.add(key);
|
|
191
|
-
}
|
|
192
|
-
return next;
|
|
193
|
-
});
|
|
194
|
-
}, []);
|
|
195
|
-
const grouped = useTableGroupedRows<Person>({
|
|
196
|
-
data: rows,
|
|
197
|
-
groupBy: p => p.team,
|
|
198
|
-
collapsedGroups: collapsed,
|
|
199
|
-
onToggleGroup,
|
|
200
|
-
getRowKey: p => p.id,
|
|
201
|
-
});
|
|
202
|
-
return (
|
|
203
|
-
<>
|
|
204
|
-
<button
|
|
205
|
-
type="button"
|
|
206
|
-
onClick={() =>
|
|
207
|
-
setRows([...people, {id: 'd', name: 'Dave', team: 'Core'}])
|
|
208
|
-
}>
|
|
209
|
-
add
|
|
210
|
-
</button>
|
|
211
|
-
<Table
|
|
212
|
-
data={grouped.data}
|
|
213
|
-
columns={columns}
|
|
214
|
-
idKey={grouped.idKey}
|
|
215
|
-
plugins={{grouped: grouped.plugin}}
|
|
216
|
-
/>
|
|
217
|
-
</>
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
render(<ChangingHarness />);
|
|
221
|
-
// Core collapsed initially: Alice hidden.
|
|
222
|
-
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
|
|
223
|
-
// Add a new Core member; Core must stay collapsed (keyed by group value).
|
|
224
|
-
fireEvent.click(screen.getByText('add'));
|
|
225
|
-
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
|
|
226
|
-
expect(screen.queryByText('Dave')).not.toBeInTheDocument();
|
|
227
|
-
// Count reflects the new member (3), header still present.
|
|
228
|
-
expect(screen.getByText('Core')).toBeInTheDocument();
|
|
229
|
-
expect(screen.getByText('(3)')).toBeInTheDocument();
|
|
230
|
-
});
|
|
231
|
-
});
|
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
'use client';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* @file useTableGroupedRows.tsx
|
|
7
|
-
* @input React, StyleX, Icon, Table types + the flat data array
|
|
8
|
-
* @output Exports useTableGroupedRows hook + config/result types
|
|
9
|
-
* @position Grouped-rows plugin; consumed by Table via plugins prop
|
|
10
|
-
*
|
|
11
|
-
* SYNC: When modified, update these files to stay in sync:
|
|
12
|
-
* - /packages/core/src/Table/index.ts (exports)
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import {useCallback, useMemo, type ReactNode} from 'react';
|
|
16
|
-
import * as stylex from '@stylexjs/stylex';
|
|
17
|
-
import {
|
|
18
|
-
spacingVars,
|
|
19
|
-
colorVars,
|
|
20
|
-
fontWeightVars,
|
|
21
|
-
} from '../../../theme/tokens.stylex';
|
|
22
|
-
import {Icon} from '../../../Icon';
|
|
23
|
-
import type {TablePlugin} from '../../types';
|
|
24
|
-
|
|
25
|
-
// A synthetic group-header row injected into the flattened data. Real rows
|
|
26
|
-
// never carry this marker.
|
|
27
|
-
const GROUP_HEADER = Symbol('tableGroupHeader');
|
|
28
|
-
|
|
29
|
-
interface GroupHeader {
|
|
30
|
-
[GROUP_HEADER]: true;
|
|
31
|
-
groupKey: string;
|
|
32
|
-
count: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function isGroupHeader(item: unknown): item is GroupHeader {
|
|
36
|
-
return (
|
|
37
|
-
typeof item === 'object' &&
|
|
38
|
-
item !== null &&
|
|
39
|
-
(item as Record<symbol, unknown>)[GROUP_HEADER] === true
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Proxy handler: any field access beyond the marker fields resolves to `''`
|
|
44
|
-
// so user cell renderers (`item.name.toUpperCase()`) never throw on a header.
|
|
45
|
-
const HEADER_PROXY_HANDLER: ProxyHandler<Record<string | symbol, unknown>> = {
|
|
46
|
-
// eslint-disable-next-line @typescript-eslint/promise-function-async -- Proxy get trap, not a promise-returning fn
|
|
47
|
-
get(t: Record<string | symbol, unknown>, prop: string | symbol): unknown {
|
|
48
|
-
if (prop === GROUP_HEADER || prop === 'groupKey' || prop === 'count') {
|
|
49
|
-
return t[prop];
|
|
50
|
-
}
|
|
51
|
-
return prop in t ? t[prop] : '';
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Build a synthetic header row wrapped in a Proxy so arbitrary field access
|
|
57
|
-
* from user cell renderers (e.g. `item.name.toUpperCase()`) resolves to `''`
|
|
58
|
-
* instead of throwing — BaseTable evaluates `col.renderCell(item)` on every
|
|
59
|
-
* row (including synthetic headers) before `transformBodyRow` can replace the
|
|
60
|
-
* row's cells. `transformBodyRow` then discards those cells and renders a
|
|
61
|
-
* single full-width header cell.
|
|
62
|
-
*/
|
|
63
|
-
function makeHeader<T extends Record<string, unknown>>(
|
|
64
|
-
groupKey: string,
|
|
65
|
-
count: number,
|
|
66
|
-
): T {
|
|
67
|
-
const target: Record<string | symbol, unknown> = {
|
|
68
|
-
[GROUP_HEADER]: true,
|
|
69
|
-
groupKey,
|
|
70
|
-
count,
|
|
71
|
-
};
|
|
72
|
-
return new Proxy(target, HEADER_PROXY_HANDLER) as unknown as T;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Configuration for {@link useTableGroupedRows}. */
|
|
76
|
-
export interface UseTableGroupedRowsConfig<T extends Record<string, unknown>> {
|
|
77
|
-
/** The flat data to group. */
|
|
78
|
-
data: T[];
|
|
79
|
-
/** Derive the group key for a row. Rows with the same key share a section. */
|
|
80
|
-
groupBy: (item: T) => string;
|
|
81
|
-
/** Set of currently-collapsed group keys. */
|
|
82
|
-
collapsedGroups: Set<string>;
|
|
83
|
-
/** Called with a group key when its header is toggled. */
|
|
84
|
-
onToggleGroup: (groupKey: string) => void;
|
|
85
|
-
/**
|
|
86
|
-
* Custom renderer for a group header's content (right of the chevron).
|
|
87
|
-
* Defaults to `<groupKey> (<count>)`.
|
|
88
|
-
*/
|
|
89
|
-
renderGroupHeader?: (
|
|
90
|
-
groupKey: string,
|
|
91
|
-
count: number,
|
|
92
|
-
collapsed: boolean,
|
|
93
|
-
) => ReactNode;
|
|
94
|
-
/** Stable key for a real row. Falls back to a positional key when omitted. */
|
|
95
|
-
getRowKey?: (item: T) => string;
|
|
96
|
-
/** Explicit group ordering; groups not listed keep first-seen order after these. */
|
|
97
|
-
groupOrder?: string[];
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export interface UseTableGroupedRowsResult<T extends Record<string, unknown>> {
|
|
101
|
-
/** Ready-to-use plugin for `<Table plugins>`. */
|
|
102
|
-
plugin: TablePlugin<T>;
|
|
103
|
-
/** Flattened rows: `[header, ...visibleRows, header, ...visibleRows]`. */
|
|
104
|
-
data: T[];
|
|
105
|
-
/**
|
|
106
|
-
* Row-key resolver (also keys synthetic headers as `__group_<key>`). Pass to
|
|
107
|
-
* `<Table idKey>` — named for parallelism with the Table prop:
|
|
108
|
-
* `<Table idKey={grouped.idKey} />`.
|
|
109
|
-
*/
|
|
110
|
-
idKey: (item: T) => string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const styles = stylex.create({
|
|
114
|
-
headerRow: {
|
|
115
|
-
cursor: 'pointer',
|
|
116
|
-
userSelect: 'none',
|
|
117
|
-
backgroundColor: colorVars['--color-background-muted'],
|
|
118
|
-
// Divider beneath each group header row (Ernest review #2).
|
|
119
|
-
borderBottomWidth: '1px',
|
|
120
|
-
borderBottomStyle: 'solid',
|
|
121
|
-
borderBottomColor: colorVars['--color-border'],
|
|
122
|
-
},
|
|
123
|
-
headerCell: {
|
|
124
|
-
paddingBlock: spacingVars['--spacing-2'],
|
|
125
|
-
// No inline start padding so the chevron aligns with the table's leading
|
|
126
|
-
// edge (Ernest review #1).
|
|
127
|
-
paddingInlineStart: spacingVars['--spacing-1'],
|
|
128
|
-
paddingInlineEnd: spacingVars['--spacing-3'],
|
|
129
|
-
},
|
|
130
|
-
headerInner: {
|
|
131
|
-
display: 'flex',
|
|
132
|
-
alignItems: 'center',
|
|
133
|
-
gap: spacingVars['--spacing-1'],
|
|
134
|
-
},
|
|
135
|
-
// Standalone chevron button with no heavy chrome (transparent, borderless,
|
|
136
|
-
// zero padding) so the icon sits flush with the start of the table
|
|
137
|
-
// (Ernest review #1) while staying keyboard-operable.
|
|
138
|
-
chevron: {
|
|
139
|
-
display: 'inline-flex',
|
|
140
|
-
alignItems: 'center',
|
|
141
|
-
justifyContent: 'center',
|
|
142
|
-
flexShrink: '0',
|
|
143
|
-
padding: 0,
|
|
144
|
-
margin: 0,
|
|
145
|
-
background: 'transparent',
|
|
146
|
-
border: 'none',
|
|
147
|
-
cursor: 'pointer',
|
|
148
|
-
color: {
|
|
149
|
-
default: colorVars['--color-icon-secondary'],
|
|
150
|
-
':hover': colorVars['--color-icon-primary'],
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
chevronIcon: {
|
|
154
|
-
display: 'inline-flex',
|
|
155
|
-
transitionProperty: 'transform',
|
|
156
|
-
transitionDuration: '150ms',
|
|
157
|
-
},
|
|
158
|
-
chevronExpanded: {
|
|
159
|
-
transform: 'rotate(90deg)',
|
|
160
|
-
},
|
|
161
|
-
// Emphasized body text — same size as body, heavier weight (Ernest #3).
|
|
162
|
-
label: {
|
|
163
|
-
fontWeight: fontWeightVars['--font-weight-semibold'],
|
|
164
|
-
color: colorVars['--color-text-primary'],
|
|
165
|
-
},
|
|
166
|
-
count: {
|
|
167
|
-
fontWeight: fontWeightVars['--font-weight-normal'],
|
|
168
|
-
color: colorVars['--color-text-secondary'],
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Groups a flat data array into collapsible section rows. Each distinct
|
|
174
|
-
* `groupBy` value becomes a full-width section-header row with a chevron
|
|
175
|
-
* toggle, the group label, and a member count; collapsing hides that group's
|
|
176
|
-
* data rows while keeping the header visible.
|
|
177
|
-
*
|
|
178
|
-
* Mirrors {@link useTableRowExpansionState}: the consumer owns the
|
|
179
|
-
* `collapsedGroups` set and this hook returns `{data, plugin, idKey}` —
|
|
180
|
-
* pass all three to `<Table>`.
|
|
181
|
-
*
|
|
182
|
-
* @example
|
|
183
|
-
* ```tsx
|
|
184
|
-
* const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
185
|
-
* const grouped = useTableGroupedRows({
|
|
186
|
-
* data: rows,
|
|
187
|
-
* groupBy: r => r.team,
|
|
188
|
-
* collapsedGroups: collapsed,
|
|
189
|
-
* onToggleGroup: key =>
|
|
190
|
-
* setCollapsed(prev => {
|
|
191
|
-
* const next = new Set(prev);
|
|
192
|
-
* next.has(key) ? next.delete(key) : next.add(key);
|
|
193
|
-
* return next;
|
|
194
|
-
* }),
|
|
195
|
-
* getRowKey: r => r.id,
|
|
196
|
-
* });
|
|
197
|
-
* <Table
|
|
198
|
-
* data={grouped.data}
|
|
199
|
-
* columns={columns}
|
|
200
|
-
* idKey={grouped.idKey}
|
|
201
|
-
* plugins={{grouped: grouped.plugin}}
|
|
202
|
-
* />;
|
|
203
|
-
* ```
|
|
204
|
-
*/
|
|
205
|
-
export function useTableGroupedRows<T extends Record<string, unknown>>(
|
|
206
|
-
config: UseTableGroupedRowsConfig<T>,
|
|
207
|
-
): UseTableGroupedRowsResult<T> {
|
|
208
|
-
const {
|
|
209
|
-
data,
|
|
210
|
-
groupBy,
|
|
211
|
-
collapsedGroups,
|
|
212
|
-
onToggleGroup,
|
|
213
|
-
renderGroupHeader,
|
|
214
|
-
getRowKey: getRowKeyProp,
|
|
215
|
-
groupOrder,
|
|
216
|
-
} = config;
|
|
217
|
-
|
|
218
|
-
const flattened = useMemo((): T[] => {
|
|
219
|
-
if (data.length === 0) {
|
|
220
|
-
return [];
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Group preserving first-seen order.
|
|
224
|
-
const groups = new Map<string, T[]>();
|
|
225
|
-
for (const item of data) {
|
|
226
|
-
const key = groupBy(item);
|
|
227
|
-
const bucket = groups.get(key);
|
|
228
|
-
if (bucket) {
|
|
229
|
-
bucket.push(item);
|
|
230
|
-
} else {
|
|
231
|
-
groups.set(key, [item]);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Determine iteration order.
|
|
236
|
-
let keys = [...groups.keys()];
|
|
237
|
-
if (groupOrder && groupOrder.length > 0) {
|
|
238
|
-
const ordered = groupOrder.filter(k => groups.has(k));
|
|
239
|
-
const rest = keys.filter(k => !groupOrder.includes(k));
|
|
240
|
-
keys = [...ordered, ...rest];
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const out: T[] = [];
|
|
244
|
-
for (const key of keys) {
|
|
245
|
-
const rows = groups.get(key) ?? [];
|
|
246
|
-
out.push(makeHeader<T>(key, rows.length));
|
|
247
|
-
if (!collapsedGroups.has(key)) {
|
|
248
|
-
out.push(...rows);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return out;
|
|
252
|
-
}, [data, groupBy, collapsedGroups, groupOrder]);
|
|
253
|
-
|
|
254
|
-
// Positional fallback index, built once per flattened array so key lookup
|
|
255
|
-
// stays O(1) instead of O(n) per row (which would make table keying O(n²)).
|
|
256
|
-
const positionByItem = useMemo(() => {
|
|
257
|
-
if (getRowKeyProp) {
|
|
258
|
-
return null;
|
|
259
|
-
}
|
|
260
|
-
const map = new Map<T, number>();
|
|
261
|
-
for (let i = 0; i < flattened.length; i++) {
|
|
262
|
-
map.set(flattened[i], i);
|
|
263
|
-
}
|
|
264
|
-
return map;
|
|
265
|
-
}, [flattened, getRowKeyProp]);
|
|
266
|
-
|
|
267
|
-
const idKey = useCallback(
|
|
268
|
-
(item: T): string => {
|
|
269
|
-
if (isGroupHeader(item)) {
|
|
270
|
-
return `__group_${item.groupKey}`;
|
|
271
|
-
}
|
|
272
|
-
if (getRowKeyProp) {
|
|
273
|
-
return getRowKeyProp(item);
|
|
274
|
-
}
|
|
275
|
-
return String(positionByItem?.get(item) ?? -1);
|
|
276
|
-
},
|
|
277
|
-
[getRowKeyProp, positionByItem],
|
|
278
|
-
);
|
|
279
|
-
|
|
280
|
-
const plugin = useMemo(
|
|
281
|
-
(): TablePlugin<T> => ({
|
|
282
|
-
// Replace a header row's pre-rendered cells with one full-width cell.
|
|
283
|
-
transformBodyRow(props, item) {
|
|
284
|
-
if (!isGroupHeader(item)) {
|
|
285
|
-
return props;
|
|
286
|
-
}
|
|
287
|
-
const header = item as unknown as GroupHeader;
|
|
288
|
-
const collapsed = collapsedGroups.has(header.groupKey);
|
|
289
|
-
const toggle = () => onToggleGroup(header.groupKey);
|
|
290
|
-
const content: ReactNode = renderGroupHeader ? (
|
|
291
|
-
renderGroupHeader(header.groupKey, header.count, collapsed)
|
|
292
|
-
) : (
|
|
293
|
-
<span {...stylex.props(styles.label)}>
|
|
294
|
-
{header.groupKey}{' '}
|
|
295
|
-
<span {...stylex.props(styles.count)}>({header.count})</span>
|
|
296
|
-
</span>
|
|
297
|
-
);
|
|
298
|
-
return {
|
|
299
|
-
...props,
|
|
300
|
-
htmlProps: {
|
|
301
|
-
...props.htmlProps,
|
|
302
|
-
// Convenience: clicking anywhere on the row toggles it. The chevron
|
|
303
|
-
// button below is the accessible, keyboard-operable control, so the
|
|
304
|
-
// row keeps its implicit `row` role (no role override here).
|
|
305
|
-
onClick: toggle,
|
|
306
|
-
'aria-expanded': !collapsed,
|
|
307
|
-
},
|
|
308
|
-
styles: [...props.styles, styles.headerRow],
|
|
309
|
-
children: (
|
|
310
|
-
// colSpan larger than the column count is clamped by the browser
|
|
311
|
-
// to the actual number of columns, so the header always spans the
|
|
312
|
-
// full width without the plugin knowing the column count.
|
|
313
|
-
<td colSpan={999} {...stylex.props(styles.headerCell)}>
|
|
314
|
-
<span {...stylex.props(styles.headerInner)}>
|
|
315
|
-
{/* Standalone chevron button, flush with the table's start
|
|
316
|
-
edge (no heavy button chrome) — the keyboard control. */}
|
|
317
|
-
<button
|
|
318
|
-
type="button"
|
|
319
|
-
{...stylex.props(styles.chevron)}
|
|
320
|
-
onClick={e => {
|
|
321
|
-
e.stopPropagation();
|
|
322
|
-
toggle();
|
|
323
|
-
}}
|
|
324
|
-
aria-label={
|
|
325
|
-
collapsed
|
|
326
|
-
? `Expand group ${header.groupKey}`
|
|
327
|
-
: `Collapse group ${header.groupKey}`
|
|
328
|
-
}
|
|
329
|
-
aria-expanded={!collapsed}>
|
|
330
|
-
<span
|
|
331
|
-
{...stylex.props(
|
|
332
|
-
styles.chevronIcon,
|
|
333
|
-
!collapsed && styles.chevronExpanded,
|
|
334
|
-
)}>
|
|
335
|
-
<Icon icon="chevronRight" size="xsm" />
|
|
336
|
-
</span>
|
|
337
|
-
</button>
|
|
338
|
-
{content}
|
|
339
|
-
</span>
|
|
340
|
-
</td>
|
|
341
|
-
),
|
|
342
|
-
};
|
|
343
|
-
},
|
|
344
|
-
}),
|
|
345
|
-
[collapsedGroups, onToggleGroup, renderGroupHeader],
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
return {plugin, data: flattened, idKey};
|
|
349
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
/** @type {import('../docs-types').ComponentDoc} */
|
|
4
|
-
|
|
5
|
-
export const docs = {
|
|
6
|
-
name: 'useTableGroupedRows',
|
|
7
|
-
subComponentOf: 'Table',
|
|
8
|
-
displayName: 'useTableGroupedRows',
|
|
9
|
-
description:
|
|
10
|
-
'Hook that groups a flat data array into collapsible section rows. Each distinct groupBy value becomes a full-width section-header row with a chevron toggle, the group label, and a member count; collapsing hides that group\u2019s data rows while keeping the header visible. Mirrors useTableRowExpansionState: the consumer owns the collapsedGroups set and the hook returns {data, plugin, idKey} \u2014 pass all three to Table (data, plugins, and idKey respectively).',
|
|
11
|
-
props: [
|
|
12
|
-
{
|
|
13
|
-
name: 'data',
|
|
14
|
-
type: 'T[]',
|
|
15
|
-
description: 'The flat data to group.',
|
|
16
|
-
required: true,
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
name: 'groupBy',
|
|
20
|
-
type: '(item: T) => string',
|
|
21
|
-
description:
|
|
22
|
-
'Derive the group key for a row. Rows with the same key share a section.',
|
|
23
|
-
required: true,
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
name: 'collapsedGroups',
|
|
27
|
-
type: 'Set<string>',
|
|
28
|
-
description: 'Set of currently-collapsed group keys.',
|
|
29
|
-
required: true,
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: 'onToggleGroup',
|
|
33
|
-
type: '(groupKey: string) => void',
|
|
34
|
-
description: 'Called with a group key when its header is toggled.',
|
|
35
|
-
required: true,
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
name: 'renderGroupHeader',
|
|
39
|
-
type: '(groupKey: string, count: number, collapsed: boolean) => ReactNode',
|
|
40
|
-
description:
|
|
41
|
-
"Custom renderer for a group header's content (right of the chevron). Defaults to `<groupKey> (<count>)`.",
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
name: 'getRowKey',
|
|
45
|
-
type: '(item: T) => string',
|
|
46
|
-
description:
|
|
47
|
-
'Stable key for a real row. Falls back to a positional key when omitted.',
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
name: 'groupOrder',
|
|
51
|
-
type: 'string[]',
|
|
52
|
-
description:
|
|
53
|
-
'Explicit group ordering; groups not listed keep first-seen order after these.',
|
|
54
|
-
},
|
|
55
|
-
],
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
/** @type {import('../docs-types').TranslationDoc} */
|
|
59
|
-
export const docsDense = {
|
|
60
|
-
description:
|
|
61
|
-
'Groups a flat data array into collapsible section rows. Each groupBy value becomes a full-width header (chevron + label + count); collapsing hides its rows. Returns {data, plugin, idKey} \u2014 pass to Table data / plugins / idKey. Consumer owns the collapsedGroups set.',
|
|
62
|
-
propDescriptions: {
|
|
63
|
-
data: 'The flat data to group.',
|
|
64
|
-
groupBy: 'Derive a row\u2019s group key. Same key = same section.',
|
|
65
|
-
collapsedGroups: 'Set of currently-collapsed group keys.',
|
|
66
|
-
onToggleGroup: 'Called with the group key when a header is toggled.',
|
|
67
|
-
renderGroupHeader: "Custom header content (right of chevron). Default '<key> (<count>)'.",
|
|
68
|
-
getRowKey: 'Stable key for a real row; positional fallback when omitted.',
|
|
69
|
-
groupOrder: 'Pin these group keys first; others keep first-seen order.',
|
|
70
|
-
},
|
|
71
|
-
};
|