jattac.libs.web.responsive-table 0.12.0-rc.4 → 0.13.0
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/README.md +92 -13
- package/dist/Hooks/useTablePlugins.d.ts +0 -8
- package/dist/Plugins/IResponsiveTablePlugin.d.ts +0 -6
- package/dist/UI/DetailRow.d.ts +1 -2
- package/dist/UI/ResponsiveTable.d.ts +0 -11
- package/dist/UI/TableBodyRow.d.ts +6 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.es.js +156 -327
- package/dist/index.es.js.map +1 -1
- package/dist/index.js +155 -327
- package/dist/index.js.map +1 -1
- package/docs/api.md +102 -22
- package/docs/development.md +1 -2
- package/docs/examples.md +57 -1
- package/docs/expand-collapse.md +630 -0
- package/docs/features.md +6 -0
- package/docs/recommendations.md +376 -0
- package/package.json +1 -1
- package/dist/Plugins/InfiniteScrollPlugin.d.ts +0 -13
- package/dist/UI/InfiniteTable.d.ts +0 -46
package/docs/examples.md
CHANGED
|
@@ -16,7 +16,8 @@ This guide details standard implementation patterns for the ResponsiveTable comp
|
|
|
16
16
|
* [Server-Side Search with dataSource](#10-server-side-search-with-datasource)
|
|
17
17
|
* [Observing dataSource State: Callbacks](#11-observing-datasource-state-callbacks)
|
|
18
18
|
* [Imperative Control via Ref](#12-imperative-control-via-ref)
|
|
19
|
-
* [Error Handling and Retry](#13-error-handling-and-retry)
|
|
19
|
+
* [Error Handling and Retry](#13-error-handling-and-retry)
|
|
20
|
+
* [Expandable Rows](#14-expandable-rows)
|
|
20
21
|
|
|
21
22
|
---
|
|
22
23
|
|
|
@@ -349,5 +350,60 @@ export const ResilientTable = () => (
|
|
|
349
350
|
);
|
|
350
351
|
```
|
|
351
352
|
|
|
353
|
+
### 14. Expandable Rows
|
|
354
|
+
Attach collapsible detail panels below any row using `expandRowRenderer`. Return `null` for rows that should not be expandable. Provide `selectionProps.rowIdKey` to ensure expanded panels survive re-sorts and filter changes.
|
|
355
|
+
|
|
356
|
+
```tsx
|
|
357
|
+
import ResponsiveTable from 'jattac.libs.web.responsive-table';
|
|
358
|
+
|
|
359
|
+
type Order = {
|
|
360
|
+
id: string;
|
|
361
|
+
reference: string;
|
|
362
|
+
customer: string;
|
|
363
|
+
total: number;
|
|
364
|
+
lineItems: { sku: string; qty: number; price: number }[];
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const columns: ColumnDefinition<Order>[] = [
|
|
368
|
+
{ displayLabel: 'Reference', cellRenderer: (o) => o.reference },
|
|
369
|
+
{ displayLabel: 'Customer', cellRenderer: (o) => o.customer },
|
|
370
|
+
{ displayLabel: 'Total', cellRenderer: (o) => `$${o.total.toFixed(2)}` },
|
|
371
|
+
];
|
|
372
|
+
|
|
373
|
+
export const ExpandableOrderTable = () => (
|
|
374
|
+
<ResponsiveTable
|
|
375
|
+
data={orders}
|
|
376
|
+
columnDefinitions={columns}
|
|
377
|
+
// Stable expand state: survives sort and filter
|
|
378
|
+
selectionProps={{ rowIdKey: 'id', onSelectionChange: () => {} }}
|
|
379
|
+
// Return null for orders with no line items — no toggle rendered for those rows
|
|
380
|
+
expandRowRenderer={(order) =>
|
|
381
|
+
order.lineItems.length === 0 ? null : (
|
|
382
|
+
<table style={{ width: '100%', padding: '0.75rem 1.5rem', fontSize: '0.875rem' }}>
|
|
383
|
+
<thead>
|
|
384
|
+
<tr>
|
|
385
|
+
<th style={{ textAlign: 'left' }}>SKU</th>
|
|
386
|
+
<th style={{ textAlign: 'center' }}>Qty</th>
|
|
387
|
+
<th style={{ textAlign: 'right' }}>Unit Price</th>
|
|
388
|
+
</tr>
|
|
389
|
+
</thead>
|
|
390
|
+
<tbody>
|
|
391
|
+
{order.lineItems.map((item) => (
|
|
392
|
+
<tr key={item.sku}>
|
|
393
|
+
<td>{item.sku}</td>
|
|
394
|
+
<td style={{ textAlign: 'center' }}>{item.qty}</td>
|
|
395
|
+
<td style={{ textAlign: 'right' }}>${item.price.toFixed(2)}</td>
|
|
396
|
+
</tr>
|
|
397
|
+
))}
|
|
398
|
+
</tbody>
|
|
399
|
+
</table>
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
/>
|
|
403
|
+
);
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
For the complete feature reference including lazy mounting, keyboard accessibility, combining with `dataSource`, and CSS customization, see the **[Row Expansion and Collapse Guide](./expand-collapse.md)**.
|
|
407
|
+
|
|
352
408
|
---
|
|
353
409
|
**Previous:** [Overview](../README.md) | **Next:** [Functional Capabilities](./features.md)
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
# Row Expansion and Collapse
|
|
2
|
+
## Progressive Disclosure of Row Detail
|
|
3
|
+
|
|
4
|
+
Expandable rows reveal arbitrary content below any row on demand. A chevron in a dedicated 2rem left column indicates state (▶ collapsed, ▾ expanded). The feature is surface-agnostic — it behaves identically in the desktop table layout and the mobile card layout — and composes transparently with all other table features: sorting, filtering, selection, `onRowClick`, and `dataSource`.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
[← Previous: Technical Implementation Guide](./examples.md) | [Return to API Reference →](./api.md)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Table of Contents
|
|
13
|
+
|
|
14
|
+
- [Props at a Glance](#props-at-a-glance)
|
|
15
|
+
- [expandRowRenderer](#expandrowrenderer)
|
|
16
|
+
- [Signature](#signature)
|
|
17
|
+
- [All Rows Expandable](#all-rows-expandable)
|
|
18
|
+
- [Selective Expansion](#selective-expansion)
|
|
19
|
+
- [Using rowIndex](#using-rowindex)
|
|
20
|
+
- [expandChevronClassName](#expandchevronclassname)
|
|
21
|
+
- [Overriding Color](#overriding-color)
|
|
22
|
+
- [Overriding Size](#overriding-size)
|
|
23
|
+
- [Combining Overrides](#combining-overrides)
|
|
24
|
+
- [Expansion State and Row Identity](#expansion-state-and-row-identity)
|
|
25
|
+
- [Lazy Mounting](#lazy-mounting)
|
|
26
|
+
- [Multiple Rows Expanded Simultaneously](#multiple-rows-expanded-simultaneously)
|
|
27
|
+
- [Accessibility and Keyboard Navigation](#accessibility-and-keyboard-navigation)
|
|
28
|
+
- [Combining with onRowClick](#combining-with-onrowclick)
|
|
29
|
+
- [Combining with Selection](#combining-with-selection)
|
|
30
|
+
- [Combining with dataSource](#combining-with-datasource)
|
|
31
|
+
- [Common Patterns](#common-patterns)
|
|
32
|
+
- [Master-Detail](#master-detail-orders--line-items)
|
|
33
|
+
- [Inline Editing Panel](#inline-editing-panel)
|
|
34
|
+
- [Nested Table](#nested-table)
|
|
35
|
+
- [Charts and Rich Media](#charts-and-rich-media)
|
|
36
|
+
- [Visual Anatomy](#visual-anatomy)
|
|
37
|
+
- [Animation System](#animation-system)
|
|
38
|
+
- [CSS Customization Reference](#css-customization-reference)
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Props at a Glance
|
|
43
|
+
|
|
44
|
+
| Prop | Type | Default | Description |
|
|
45
|
+
| :--- | :--- | :--- | :--- |
|
|
46
|
+
| `expandRowRenderer` | `(row: TData, rowIndex: number) => ReactNode` | — | Renderer for the detail panel. Return `null` or `undefined` to suppress the toggle on that row. |
|
|
47
|
+
| `expandChevronClassName` | `string` | — | CSS class applied to the chevron `<span>`. Use to override color, size, or other styles. Do not override `transform` or `transition`. |
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## `expandRowRenderer`
|
|
52
|
+
|
|
53
|
+
### Signature
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
expandRowRenderer?: (row: TData, rowIndex: number) => React.ReactNode
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The renderer function is called for every row on each render pass. The return value determines both whether the toggle is shown and what content is displayed when expanded.
|
|
60
|
+
|
|
61
|
+
| Return value | Effect |
|
|
62
|
+
| :--- | :--- |
|
|
63
|
+
| A `ReactNode` | Chevron appears in the expand column. Content is shown when expanded. |
|
|
64
|
+
| `null` or `undefined` | No chevron is rendered for that row. The row renders flat with no visual affordance. |
|
|
65
|
+
|
|
66
|
+
### All Rows Expandable
|
|
67
|
+
|
|
68
|
+
The simplest case: every row receives a toggle.
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
import ResponsiveTable from 'jattac.libs.web.responsive-table';
|
|
72
|
+
|
|
73
|
+
<ResponsiveTable
|
|
74
|
+
data={orders}
|
|
75
|
+
columnDefinitions={columns}
|
|
76
|
+
expandRowRenderer={(order) => (
|
|
77
|
+
<div style={{ padding: '1rem' }}>
|
|
78
|
+
<strong>Notes:</strong> {order.notes}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
/>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Selective Expansion
|
|
85
|
+
|
|
86
|
+
Return `null` or `undefined` for rows that should not be expandable. The chevron is completely absent — not hidden — for those rows, so there is no visual affordance for non-expandable rows.
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
<ResponsiveTable
|
|
90
|
+
data={orders}
|
|
91
|
+
columnDefinitions={columns}
|
|
92
|
+
expandRowRenderer={(order) =>
|
|
93
|
+
order.lineItems.length > 0
|
|
94
|
+
? <OrderLineItems order={order} />
|
|
95
|
+
: null
|
|
96
|
+
}
|
|
97
|
+
/>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This is the canonical pattern for master-detail layouts where only some records have associated detail data.
|
|
101
|
+
|
|
102
|
+
### Using `rowIndex`
|
|
103
|
+
|
|
104
|
+
The renderer receives the **display-order** index of the row as its second argument. This is the post-sort, post-filter position in the rendered list — it changes when the user re-sorts or filters.
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
<ResponsiveTable
|
|
108
|
+
data={employees}
|
|
109
|
+
columnDefinitions={columns}
|
|
110
|
+
expandRowRenderer={(employee, rowIndex) => (
|
|
111
|
+
<EmployeeDetail
|
|
112
|
+
employee={employee}
|
|
113
|
+
zebra={rowIndex % 2 === 0} // alternating panel background
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
/>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
> **Do not use `rowIndex` as a stable identifier.** Its value changes whenever the visible order changes. For data correlation that must survive re-renders, use the row object's own identifier field (`row.id`, etc.).
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## `expandChevronClassName`
|
|
124
|
+
|
|
125
|
+
### Signature
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
expandChevronClassName?: string
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Applies a CSS class to the `<span>` wrapping the chevron icon. The chevron defaults to `1.125rem` and inherits its color from `--primary-color` (`#3b82f6` fallback). Use this prop to override any style without forking the component.
|
|
132
|
+
|
|
133
|
+
> **Do not override `transform` or `transition`** on this class. Both properties drive the rotation animation and will be reset internally on each toggle.
|
|
134
|
+
|
|
135
|
+
### Overriding Color
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
<ResponsiveTable
|
|
139
|
+
expandChevronClassName={styles.customChevron}
|
|
140
|
+
expandRowRenderer={(row) => <Detail row={row} />}
|
|
141
|
+
data={rows}
|
|
142
|
+
columnDefinitions={columns}
|
|
143
|
+
/>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```css
|
|
147
|
+
.customChevron {
|
|
148
|
+
color: #7c3aed; /* violet accent */
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Prefer a CSS variable or design token over a hardcoded hex value to maintain theming consistency:
|
|
153
|
+
|
|
154
|
+
```css
|
|
155
|
+
.customChevron {
|
|
156
|
+
color: var(--brand-accent);
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Overriding Size
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
<ResponsiveTable
|
|
164
|
+
expandChevronClassName={styles.compactChevron}
|
|
165
|
+
expandRowRenderer={(row) => <Detail row={row} />}
|
|
166
|
+
data={rows}
|
|
167
|
+
columnDefinitions={columns}
|
|
168
|
+
/>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```css
|
|
172
|
+
.compactChevron {
|
|
173
|
+
font-size: 0.9rem; /* smaller than the 1.125rem default */
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Combining Overrides
|
|
178
|
+
|
|
179
|
+
Multiple style properties can be applied in a single class:
|
|
180
|
+
|
|
181
|
+
```css
|
|
182
|
+
.brandedChevron {
|
|
183
|
+
color: var(--brand-primary);
|
|
184
|
+
font-size: 1rem;
|
|
185
|
+
/* Do NOT set transform or transition here */
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
<ResponsiveTable
|
|
191
|
+
expandChevronClassName={styles.brandedChevron}
|
|
192
|
+
expandRowRenderer={(row) => <Detail row={row} />}
|
|
193
|
+
data={rows}
|
|
194
|
+
columnDefinitions={columns}
|
|
195
|
+
/>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Expansion State and Row Identity
|
|
201
|
+
|
|
202
|
+
Each row's expanded/collapsed state is tracked internally using a `Set` of row identifiers. The key used to identify rows is determined as follows:
|
|
203
|
+
|
|
204
|
+
| Condition | Key used |
|
|
205
|
+
| :--- | :--- |
|
|
206
|
+
| `selectionProps.rowIdKey` is provided | `row[rowIdKey]` — a stable data-derived identifier |
|
|
207
|
+
| `selectionProps` is not provided | Array index (display-order position) |
|
|
208
|
+
|
|
209
|
+
**With a stable key**, expanded panels survive re-sorts and filter changes. A user who expands "Order #1042" and then re-sorts by date will find "Order #1042" still expanded in its new position.
|
|
210
|
+
|
|
211
|
+
**Without a stable key**, expansion state is tied to position. Re-sorting resets all expanded panels because the indices no longer correspond to the same rows.
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
// Stable: expanded state survives sort and filter
|
|
215
|
+
<ResponsiveTable
|
|
216
|
+
data={orders}
|
|
217
|
+
columnDefinitions={columns}
|
|
218
|
+
selectionProps={{
|
|
219
|
+
rowIdKey: 'id',
|
|
220
|
+
mode: 'multiple',
|
|
221
|
+
onSelectionChange: setSelected,
|
|
222
|
+
}}
|
|
223
|
+
expandRowRenderer={(order) => <OrderLineItems order={order} />}
|
|
224
|
+
/>
|
|
225
|
+
|
|
226
|
+
// Index-based: expanded state resets on sort/filter
|
|
227
|
+
<ResponsiveTable
|
|
228
|
+
data={orders}
|
|
229
|
+
columnDefinitions={columns}
|
|
230
|
+
expandRowRenderer={(order) => <OrderLineItems order={order} />}
|
|
231
|
+
/>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
You can provide `selectionProps` solely to anchor expand state, even if row selection is not otherwise needed:
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
// selectionProps used only to provide a stable expand key
|
|
238
|
+
// No selection UI is rendered unless selection is also visually wired
|
|
239
|
+
<ResponsiveTable
|
|
240
|
+
data={orders}
|
|
241
|
+
columnDefinitions={columns}
|
|
242
|
+
selectionProps={{
|
|
243
|
+
rowIdKey: 'id',
|
|
244
|
+
onSelectionChange: () => {}, // no-op — selection result is not consumed
|
|
245
|
+
}}
|
|
246
|
+
expandRowRenderer={(order) => <OrderLineItems order={order} />}
|
|
247
|
+
/>
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Lazy Mounting
|
|
253
|
+
|
|
254
|
+
Detail panel components are **not instantiated until the first time a row is expanded**. After that, the component stays mounted permanently. Collapse is achieved entirely by CSS (`grid-template-rows: 1fr → 0fr`) rather than by unmounting.
|
|
255
|
+
|
|
256
|
+
**Implications by scenario:**
|
|
257
|
+
|
|
258
|
+
| Scenario | Behaviour |
|
|
259
|
+
| :--- | :--- |
|
|
260
|
+
| Initial table render with 100 expandable rows | 0 detail components instantiated |
|
|
261
|
+
| First expand of a row | Detail component mounts; `useEffect` hooks fire |
|
|
262
|
+
| Subsequent collapse of the same row | Component stays mounted; only CSS changes |
|
|
263
|
+
| Second expand of the same row | No remount; component resumes from its current state |
|
|
264
|
+
| Component manages form state internally | Form state (values, validation) persists across collapse/expand cycles |
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
// This API call fires on first expand, not on table render
|
|
268
|
+
function OrderDetail({ orderId }: { orderId: string }) {
|
|
269
|
+
const { data } = useOrderDetail(orderId); // fires once, on first expand
|
|
270
|
+
return <LineItemTable data={data} />;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
<ResponsiveTable
|
|
274
|
+
data={orders}
|
|
275
|
+
columnDefinitions={columns}
|
|
276
|
+
expandRowRenderer={(order) => <OrderDetail orderId={order.id} />}
|
|
277
|
+
/>
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Multiple Rows Expanded Simultaneously
|
|
283
|
+
|
|
284
|
+
Rows expand independently. Any number of rows can be open at the same time. There is no built-in accordion mode where opening one row closes others.
|
|
285
|
+
|
|
286
|
+
```tsx
|
|
287
|
+
// Alice and Bob can both be expanded — this is valid and supported
|
|
288
|
+
<ResponsiveTable
|
|
289
|
+
data={[
|
|
290
|
+
{ id: 1, name: 'Alice' },
|
|
291
|
+
{ id: 2, name: 'Bob' },
|
|
292
|
+
{ id: 3, name: 'Carol' },
|
|
293
|
+
]}
|
|
294
|
+
columnDefinitions={columns}
|
|
295
|
+
expandRowRenderer={(row) => <Detail row={row} />}
|
|
296
|
+
/>
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
If your use case requires accordion behaviour — only one row open at a time — implement it by controlling which rows return content from the renderer:
|
|
300
|
+
|
|
301
|
+
```tsx
|
|
302
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
303
|
+
|
|
304
|
+
// selectionProps provides stable keying for the single-open panel
|
|
305
|
+
<ResponsiveTable
|
|
306
|
+
data={rows}
|
|
307
|
+
columnDefinitions={columns}
|
|
308
|
+
selectionProps={{ rowIdKey: 'id', onSelectionChange: () => {} }}
|
|
309
|
+
expandRowRenderer={(row) => {
|
|
310
|
+
if (row.id !== openId) return null;
|
|
311
|
+
return <Detail row={row} onClose={() => setOpenId(null)} />;
|
|
312
|
+
}}
|
|
313
|
+
/>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
> When `openId` changes, affected rows re-render and return a different value (`null` vs content). Because expansion state is managed internally by the table, the visual toggle reflects the content availability — a row with `null` content has no toggle, and a row with content has a toggle.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Accessibility and Keyboard Navigation
|
|
321
|
+
|
|
322
|
+
The chevron in the expand column is fully accessible without any additional configuration.
|
|
323
|
+
|
|
324
|
+
| Attribute / Behaviour | Detail |
|
|
325
|
+
| :--- | :--- |
|
|
326
|
+
| `role="button"` | Chevron is announced as an interactive button to assistive technologies. |
|
|
327
|
+
| `tabIndex={0}` | Chevron is keyboard-focusable via Tab. |
|
|
328
|
+
| `aria-expanded="true/false"` | State is announced on each toggle; screen readers report the change. |
|
|
329
|
+
| **Enter** / **Space** | Toggles the panel from the keyboard. Default scroll behaviour is suppressed on Space. |
|
|
330
|
+
| `data-rt-ignore-row-click` | Chevron is excluded from the `onRowClick` event chain. Activating the toggle never fires the row click handler. |
|
|
331
|
+
|
|
332
|
+
The chevron is a separate focusable element from the row itself. When `onRowClick` is also present, both the row and the chevron are keyboard-focusable independently: Tab reaches the row, Tab again reaches the chevron in the expand column.
|
|
333
|
+
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## Combining with `onRowClick`
|
|
337
|
+
|
|
338
|
+
The expand toggle and `onRowClick` are fully isolated. Clicking the chevron never fires `onRowClick` because it carries `data-rt-ignore-row-click` internally.
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
<ResponsiveTable
|
|
342
|
+
data={orders}
|
|
343
|
+
columnDefinitions={columns}
|
|
344
|
+
onRowClick={(order) => navigate(`/orders/${order.id}`)}
|
|
345
|
+
expandRowRenderer={(order) => <OrderLineItems order={order} />}
|
|
346
|
+
/>
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Clicking the row body navigates. Clicking the chevron expands the panel. The two actions are fully independent and do not interfere with each other.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## Combining with Selection
|
|
354
|
+
|
|
355
|
+
Expand state and selection state share the same row identity key from `selectionProps.rowIdKey`. Providing a stable ID ensures both selection highlights and expanded panels survive sort and filter operations.
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
<ResponsiveTable
|
|
359
|
+
data={orders}
|
|
360
|
+
columnDefinitions={columns}
|
|
361
|
+
selectionProps={{
|
|
362
|
+
rowIdKey: 'id',
|
|
363
|
+
mode: 'multiple',
|
|
364
|
+
onSelectionChange: setSelectedOrders,
|
|
365
|
+
}}
|
|
366
|
+
expandRowRenderer={(order) =>
|
|
367
|
+
order.lineItems.length > 0
|
|
368
|
+
? <OrderLineItems order={order} />
|
|
369
|
+
: null
|
|
370
|
+
}
|
|
371
|
+
/>
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Combining with `dataSource`
|
|
377
|
+
|
|
378
|
+
`expandRowRenderer` is fully compatible with `dataSource` and its infinite scroll behaviour. As new pages load and rows append, previously expanded rows remain expanded. Newly loaded rows arrive in the collapsed state.
|
|
379
|
+
|
|
380
|
+
```tsx
|
|
381
|
+
<ResponsiveTable
|
|
382
|
+
dataSource={async ({ page, pageSize }) => {
|
|
383
|
+
const response = await api.orders.list({ page, limit: pageSize });
|
|
384
|
+
return { items: response.data, totalCount: response.total };
|
|
385
|
+
}}
|
|
386
|
+
pageSize={20}
|
|
387
|
+
columnDefinitions={columns}
|
|
388
|
+
selectionProps={{ rowIdKey: 'id', onSelectionChange: () => {} }}
|
|
389
|
+
expandRowRenderer={(order) =>
|
|
390
|
+
order.lineItems.length > 0
|
|
391
|
+
? <OrderLineItems order={order} />
|
|
392
|
+
: null
|
|
393
|
+
}
|
|
394
|
+
/>
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Providing `selectionProps.rowIdKey` is especially important with `dataSource`. Without a stable key, each new page fetch reassigns indices, resetting expand state for all currently visible rows.
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## Common Patterns
|
|
402
|
+
|
|
403
|
+
### Master-Detail (Orders → Line Items)
|
|
404
|
+
|
|
405
|
+
The canonical use case. The summary row displays key fields; expanding reveals the full record or a sub-table.
|
|
406
|
+
|
|
407
|
+
```tsx
|
|
408
|
+
type Order = {
|
|
409
|
+
id: string;
|
|
410
|
+
reference: string;
|
|
411
|
+
customer: string;
|
|
412
|
+
total: number;
|
|
413
|
+
lineItems: { sku: string; qty: number; price: number }[];
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const columns: ColumnDefinition<Order>[] = [
|
|
417
|
+
{ displayLabel: 'Reference', cellRenderer: (o) => o.reference },
|
|
418
|
+
{ displayLabel: 'Customer', cellRenderer: (o) => o.customer },
|
|
419
|
+
{ displayLabel: 'Total', cellRenderer: (o) => `$${o.total.toFixed(2)}` },
|
|
420
|
+
];
|
|
421
|
+
|
|
422
|
+
<ResponsiveTable
|
|
423
|
+
data={orders}
|
|
424
|
+
columnDefinitions={columns}
|
|
425
|
+
selectionProps={{ rowIdKey: 'id', onSelectionChange: () => {} }}
|
|
426
|
+
expandRowRenderer={(order) =>
|
|
427
|
+
order.lineItems.length === 0 ? null : (
|
|
428
|
+
<table style={{ width: '100%', padding: '0.75rem 1.5rem', fontSize: '0.875rem' }}>
|
|
429
|
+
<thead>
|
|
430
|
+
<tr>
|
|
431
|
+
<th style={{ textAlign: 'left' }}>SKU</th>
|
|
432
|
+
<th style={{ textAlign: 'center' }}>Qty</th>
|
|
433
|
+
<th style={{ textAlign: 'right' }}>Unit Price</th>
|
|
434
|
+
</tr>
|
|
435
|
+
</thead>
|
|
436
|
+
<tbody>
|
|
437
|
+
{order.lineItems.map((item) => (
|
|
438
|
+
<tr key={item.sku}>
|
|
439
|
+
<td>{item.sku}</td>
|
|
440
|
+
<td style={{ textAlign: 'center' }}>{item.qty}</td>
|
|
441
|
+
<td style={{ textAlign: 'right' }}>${item.price.toFixed(2)}</td>
|
|
442
|
+
</tr>
|
|
443
|
+
))}
|
|
444
|
+
</tbody>
|
|
445
|
+
</table>
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
/>
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### Inline Editing Panel
|
|
452
|
+
|
|
453
|
+
Reveals an edit form below the row without navigating away. Because the component stays mounted after first expand, form state (draft values, validation errors) persists across collapse/expand cycles.
|
|
454
|
+
|
|
455
|
+
```tsx
|
|
456
|
+
function EditForm({ employee, onSave }: { employee: Employee; onSave: (e: Employee) => void }) {
|
|
457
|
+
const [draft, setDraft] = useState(employee);
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<form
|
|
461
|
+
style={{ padding: '1rem 1.5rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}
|
|
462
|
+
onSubmit={(ev) => { ev.preventDefault(); onSave(draft); }}
|
|
463
|
+
>
|
|
464
|
+
<input
|
|
465
|
+
value={draft.name}
|
|
466
|
+
onChange={(ev) => setDraft({ ...draft, name: ev.target.value })}
|
|
467
|
+
data-rt-ignore-row-click
|
|
468
|
+
/>
|
|
469
|
+
<button type="submit" data-rt-ignore-row-click>Save</button>
|
|
470
|
+
</form>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
<ResponsiveTable
|
|
475
|
+
data={employees}
|
|
476
|
+
columnDefinitions={columns}
|
|
477
|
+
selectionProps={{ rowIdKey: 'id', onSelectionChange: () => {} }}
|
|
478
|
+
onRowClick={(employee) => setPreview(employee)}
|
|
479
|
+
expandRowRenderer={(employee) => (
|
|
480
|
+
<EditForm employee={employee} onSave={handleSave} />
|
|
481
|
+
)}
|
|
482
|
+
/>
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
> Add `data-rt-ignore-row-click` to interactive elements inside the detail panel when the table also uses `onRowClick`. See [Handling Interactive Elements](./handling-interactive-elements.md).
|
|
486
|
+
|
|
487
|
+
### Nested Table
|
|
488
|
+
|
|
489
|
+
Render a full child `ResponsiveTable` inside the detail panel for hierarchical data.
|
|
490
|
+
|
|
491
|
+
```tsx
|
|
492
|
+
<ResponsiveTable
|
|
493
|
+
data={departments}
|
|
494
|
+
columnDefinitions={deptColumns}
|
|
495
|
+
selectionProps={{ rowIdKey: 'id', onSelectionChange: () => {} }}
|
|
496
|
+
expandRowRenderer={(dept) =>
|
|
497
|
+
dept.employees.length === 0 ? null : (
|
|
498
|
+
<div style={{ padding: '0.75rem 1.5rem' }}>
|
|
499
|
+
<ResponsiveTable
|
|
500
|
+
data={dept.employees}
|
|
501
|
+
columnDefinitions={employeeColumns}
|
|
502
|
+
mobileBreakpoint={400}
|
|
503
|
+
/>
|
|
504
|
+
</div>
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
/>
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Charts and Rich Media
|
|
511
|
+
|
|
512
|
+
Any `ReactNode` is valid detail content. Heavy libraries (charting, map rendering, rich text editors) mount lazily on first expand, keeping initial render cost low.
|
|
513
|
+
|
|
514
|
+
```tsx
|
|
515
|
+
import { LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts';
|
|
516
|
+
|
|
517
|
+
<ResponsiveTable
|
|
518
|
+
data={assets}
|
|
519
|
+
columnDefinitions={assetColumns}
|
|
520
|
+
selectionProps={{ rowIdKey: 'id', onSelectionChange: () => {} }}
|
|
521
|
+
expandRowRenderer={(asset) => (
|
|
522
|
+
<div style={{ padding: '1rem 1.5rem', height: 240 }}>
|
|
523
|
+
<LineChart width={580} height={200} data={asset.priceHistory}>
|
|
524
|
+
<XAxis dataKey="date" />
|
|
525
|
+
<YAxis />
|
|
526
|
+
<Tooltip />
|
|
527
|
+
<Line type="monotone" dataKey="price" dot={false} />
|
|
528
|
+
</LineChart>
|
|
529
|
+
</div>
|
|
530
|
+
)}
|
|
531
|
+
/>
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
---
|
|
535
|
+
|
|
536
|
+
## Visual Anatomy
|
|
537
|
+
|
|
538
|
+
When `expandRowRenderer` returns content for a row, the following structure is rendered (desktop shown; mobile uses the same visual model in card layout):
|
|
539
|
+
|
|
540
|
+
```
|
|
541
|
+
┌─── expand column (2rem) ──┬── data columns ───────────────────────────────┐
|
|
542
|
+
│ ▶── chevron (25% opacity) │ Alice │ alice@example.com │ Admin │
|
|
543
|
+
│ -90deg, quiet idle │ │ │ │
|
|
544
|
+
└────────────────────────────┴─────────┴──────────────────────┴──────────────┘
|
|
545
|
+
┌── toggle bar (0→2rem on expand, left chevron, muted blue) ────────┐
|
|
546
|
+
│ ▾ (rotated 0deg, full opacity) │
|
|
547
|
+
└────────────────────────────────────────────────────────────────────┘
|
|
548
|
+
┌── detail panel (CSS grid: 0fr → 1fr, content inset 1.5rem) ───────┐
|
|
549
|
+
│ [rendered detail content] │
|
|
550
|
+
└────────────────────────────────────────────────────────────────────┘
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
The expand column is a dedicated `<th>`/`<td>` — the first column in the table — ensuring the chevron is always left-aligned and never overlaps data cells.
|
|
554
|
+
|
|
555
|
+
**State summary:**
|
|
556
|
+
|
|
557
|
+
| State | Chevron | Toggle bar | Detail panel |
|
|
558
|
+
| :--- | :--- | :--- | :--- |
|
|
559
|
+
| **Idle** (no expand) | Hidden (no chevron) | Hidden | Hidden |
|
|
560
|
+
| **Idle** (expandable) | 25% opacity, -90deg | Hidden (height: 0) | Hidden (0fr) |
|
|
561
|
+
| **Hover** | 60% opacity, -90deg | Hidden | Hidden |
|
|
562
|
+
| **Expanded** | 100% opacity, 0deg | 2rem, chevron left | Visible (1fr) |
|
|
563
|
+
|
|
564
|
+
---
|
|
565
|
+
|
|
566
|
+
## Animation System
|
|
567
|
+
|
|
568
|
+
All transitions are CSS-driven with no JavaScript animation loop.
|
|
569
|
+
|
|
570
|
+
**Toggle transitions:**
|
|
571
|
+
|
|
572
|
+
| Element | Technique | Duration |
|
|
573
|
+
| :--- | :--- | :--- |
|
|
574
|
+
| Chevron rotation (▶ → ▾) | `transform: rotate(-90deg) → rotate(0deg)` | 250ms ease |
|
|
575
|
+
| Chevron opacity (idle → hover) | `opacity: 0.25 → 0.6` | 150ms ease |
|
|
576
|
+
| Chevron opacity (idle → expanded) | `opacity: 0.25 → 1` | 250ms ease |
|
|
577
|
+
| Toggle bar slide in | `height: 0 → 2rem` | 250ms ease |
|
|
578
|
+
| Panel open/close | `grid-template-rows: 0fr → 1fr` | 300ms ease |
|
|
579
|
+
|
|
580
|
+
**Greeting animation (plays once per component mount):**
|
|
581
|
+
|
|
582
|
+
The chevrons perform a staggered multi-pulse wave to draw attention to the expandable feature:
|
|
583
|
+
|
|
584
|
+
```
|
|
585
|
+
@keyframes chevronGreeting:
|
|
586
|
+
0% → opacity 0, scale 0.6
|
|
587
|
+
12% → opacity 1, scale 1.3 (first pop)
|
|
588
|
+
22% → opacity 0.35, scale 1.0
|
|
589
|
+
32% → opacity 1, scale 1.15 (second pop)
|
|
590
|
+
45% → opacity 0.25, scale 1.0 (settle to idle)
|
|
591
|
+
100% → opacity 0.25, scale 1.0 (stay at idle)
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
Each row's chevron delays its animation by `rowIndex × 60ms`, creating a wave from top to bottom. The greeting times out after 3.2s via a React `useEffect` timer.
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
## CSS Customization Reference
|
|
599
|
+
|
|
600
|
+
The following CSS module classes govern the expand/collapse visual system. They are not part of the public API — use `expandChevronClassName` for chevron customization. The table is provided for reference when applying global theme overrides via CSS variables.
|
|
601
|
+
|
|
602
|
+
| Class | Element | Key properties |
|
|
603
|
+
| :--- | :--- | :--- |
|
|
604
|
+
| `.expandColumn` | `<th>` / `<td>` for the dedicated chevron column | `width: 2rem`, `min-width: 2rem`, `padding: 0`, `text-align: center` |
|
|
605
|
+
| `.expandChevron` | Chevron `<span>` | `opacity: 0.25`, `transform: rotate(-90deg)`, `transition: opacity 0.15s, transform 0.25s` |
|
|
606
|
+
| `.expandChevronGreeting` | Applied during mount greeting | `animation: chevronGreeting 2.2s ease-out forwards`, delay via `--row-idx` |
|
|
607
|
+
| `.expandChevronHovered` | Applied on row hover | `opacity: 0.6` |
|
|
608
|
+
| `.expandChevronExpanded` | Applied when the panel is open | `opacity: 1`, `transform: rotate(0deg)` |
|
|
609
|
+
| `.detailCell` | `<td>` wrapping the entire detail row | `padding: 0`, `background: rgba(0,0,0,0.012)` |
|
|
610
|
+
| `.detailCellHasContent` | Applied when renderer returns content | `padding-bottom: 0.5rem` |
|
|
611
|
+
| `.detailCellExpanded` | Applied when the panel is open | `border-left: 2px solid var(--primary-color, #3b82f6)` |
|
|
612
|
+
| `.detailToggleBar` | Toggle bar inside detail pane (base) | `height: 0`, `overflow: hidden` |
|
|
613
|
+
| `.detailToggleBarExpanded` | Toggle bar when panel is open | `height: 2rem`, `background: rgba(59,130,246,0.08)`, `cursor: pointer` |
|
|
614
|
+
| `.detailToggleChevron` | Chevron inside the toggle bar (always visible when bar is shown) | `color: var(--primary-color)`, `font-size: 1.25rem`, left-aligned |
|
|
615
|
+
| `.detailContentWrapper` | Grid container for the detail content | `grid-template-rows: 0fr`, transitions to `1fr` on expand |
|
|
616
|
+
| `.detailContentInner` | Inner content container | `overflow: hidden`, `padding-left: 1.5rem` |
|
|
617
|
+
| `.mobileDetailOuter` | Wrapper for toggle bar + content in mobile view | `overflow: hidden`, `background: rgba(0,0,0,0.012)` |
|
|
618
|
+
| `.mobileExpandRow` | Chevron row at top of mobile card | Flex container, chevron left-aligned, bottom border separator |
|
|
619
|
+
|
|
620
|
+
To apply brand colors across the entire expand/collapse system (chevron, border indicator, and selection highlights simultaneously), override the `--primary-color` variable in your global stylesheet:
|
|
621
|
+
|
|
622
|
+
```css
|
|
623
|
+
:root {
|
|
624
|
+
--primary-color: #7c3aed;
|
|
625
|
+
}
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
**Previous:** [Technical Implementation Guide](./examples.md) | **Next:** [API Reference](./api.md)
|