@elliemae/ds-data-table 3.70.0-next.3 → 3.70.0-next.4
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/cjs/TruncatedTooltipText.js +94 -0
- package/dist/cjs/TruncatedTooltipText.js.map +7 -0
- package/dist/cjs/exported-related/EditableCell.js +1 -1
- package/dist/cjs/exported-related/EditableCell.js.map +2 -2
- package/dist/cjs/parts/Cells/Cell.js +2 -2
- package/dist/cjs/parts/Cells/Cell.js.map +2 -2
- package/dist/cjs/parts/Cells/CellFactory.js +2 -2
- package/dist/cjs/parts/Cells/CellFactory.js.map +2 -2
- package/dist/cjs/parts/Headers/HeaderCellTitle.js +2 -2
- package/dist/cjs/parts/Headers/HeaderCellTitle.js.map +2 -2
- package/dist/cjs/react-desc-prop-types.js +2 -2
- package/dist/cjs/react-desc-prop-types.js.map +1 -1
- package/dist/esm/TruncatedTooltipText.js +68 -0
- package/dist/esm/TruncatedTooltipText.js.map +7 -0
- package/dist/esm/exported-related/EditableCell.js +1 -1
- package/dist/esm/exported-related/EditableCell.js.map +2 -2
- package/dist/esm/parts/Cells/Cell.js +2 -2
- package/dist/esm/parts/Cells/Cell.js.map +2 -2
- package/dist/esm/parts/Cells/CellFactory.js +2 -2
- package/dist/esm/parts/Cells/CellFactory.js.map +2 -2
- package/dist/esm/parts/Headers/HeaderCellTitle.js +2 -2
- package/dist/esm/parts/Headers/HeaderCellTitle.js.map +2 -2
- package/dist/esm/react-desc-prop-types.js +2 -2
- package/dist/esm/react-desc-prop-types.js.map +1 -1
- package/dist/types/TruncatedTooltipText.d.ts +9 -0
- package/dist/types/tests/DSDataTable.get-owner-props-arguments-slots.test.d.ts +1 -0
- package/dist/types/tests/callbacks/editableCell.events.test.d.ts +1 -0
- package/dist/types/tests/playwright/DSDataTable.slot-contracts-dynamic.test.playwright.d.ts +1 -0
- package/dist/types/tests/playwright/DSDataTableDropIndicatorTestRenderer.d.ts +1 -0
- package/dist/types/tests/render/cellStyle.test.d.ts +1 -0
- package/package.json +33 -33
- package/skills/ds-data-table-boundaries/SKILL.md +363 -0
- package/skills/ds-data-table-columns/SKILL.md +273 -0
- package/skills/ds-data-table-expandable/SKILL.md +235 -0
- package/skills/ds-data-table-feedback/SKILL.md +190 -0
- package/skills/ds-data-table-filtering/SKILL.md +322 -0
- package/skills/ds-data-table-health-check/SKILL.md +172 -0
- package/skills/ds-data-table-migration/SKILL.md +201 -0
- package/skills/ds-data-table-pagination/SKILL.md +182 -0
- package/skills/ds-data-table-row-variants/SKILL.md +260 -0
- package/skills/ds-data-table-selection/SKILL.md +449 -0
- package/skills/ds-data-table-setup/SKILL.md +229 -0
- package/skills/ds-data-table-slots/SKILL.md +257 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ds-data-table-selection
|
|
3
|
+
description: >
|
|
4
|
+
Row selection for @elliemae/ds-data-table. Pass selection prop to auto-inject the checkbox column
|
|
5
|
+
(multi-select default). selectSingle flag switches to radio-button single-select. noSelectionColumn
|
|
6
|
+
suppresses the column while keeping selection logic. multiSelectColumn / singleSelectColumn exports
|
|
7
|
+
are for customization only (spread + override). uniqueRowAccessor required — without it selection
|
|
8
|
+
silently maps to wrong rows. Select-all via header checkbox fires selectedControl='All' for both
|
|
9
|
+
select and deselect gestures; newSelection covers all in-memory rows.
|
|
10
|
+
type: core
|
|
11
|
+
library: ds-data-table
|
|
12
|
+
library_version: '3.60.0'
|
|
13
|
+
requires:
|
|
14
|
+
- ds-data-table-setup
|
|
15
|
+
sources:
|
|
16
|
+
- '@elliemae/ds-data-table:dist/types/react-desc-prop-types.d.ts'
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
Multi-select (default mode) — pass `selection` and DataTable auto-injects the checkbox column:
|
|
22
|
+
|
|
23
|
+
```jsx
|
|
24
|
+
import { DataTable } from '@elliemae/ds-data-table';
|
|
25
|
+
|
|
26
|
+
const columns = [
|
|
27
|
+
{ Header: 'Name', accessor: 'name' },
|
|
28
|
+
{ Header: 'Position', accessor: 'position' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// selection: Record<string | number, boolean | 'mixed'>
|
|
32
|
+
// 'mixed' is the indeterminate checkbox state — set by DataTable when some but not all
|
|
33
|
+
// rows on the current page are selected. Consumers read it as "partially selected".
|
|
34
|
+
// Lives in the project state manager, e.g for Redux:
|
|
35
|
+
const { selection } = useSelector(selectTableState);
|
|
36
|
+
const dispatch = useDispatch();
|
|
37
|
+
|
|
38
|
+
// Full signature: (newSelection, selectedControl, event) => void
|
|
39
|
+
// selectedControl: the uid of the row that triggered the change, or 'All' for the header checkbox
|
|
40
|
+
// With a full in-memory dataset, dispatching newSelection directly is sufficient.
|
|
41
|
+
// With AJAX pagination, intercept selectedControl === 'All' — see Select-all intent pattern below.
|
|
42
|
+
const handleSelectionChange = useCallback((newSelection) => dispatch(setSelection(newSelection)), [dispatch]);
|
|
43
|
+
|
|
44
|
+
<DataTable
|
|
45
|
+
columns={columns}
|
|
46
|
+
data={rows}
|
|
47
|
+
height={500}
|
|
48
|
+
uniqueRowAccessor="id"
|
|
49
|
+
selection={selection}
|
|
50
|
+
onSelectionChange={handleSelectionChange}
|
|
51
|
+
/>;
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Core Patterns
|
|
55
|
+
|
|
56
|
+
### Single-select — selectSingle flag
|
|
57
|
+
|
|
58
|
+
```jsx
|
|
59
|
+
// selectSingle: true switches mode to single-select (radio button column auto-injects)
|
|
60
|
+
<DataTable
|
|
61
|
+
columns={columns}
|
|
62
|
+
data={rows}
|
|
63
|
+
height={500}
|
|
64
|
+
uniqueRowAccessor="id"
|
|
65
|
+
selectSingle
|
|
66
|
+
selection={selection}
|
|
67
|
+
onSelectionChange={handleSelectionChange}
|
|
68
|
+
/>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Disabled rows
|
|
72
|
+
|
|
73
|
+
```jsx
|
|
74
|
+
// disabledRows: Record<rowUid, boolean> — rows that cannot be selected
|
|
75
|
+
const disabledRows = { 'row-1': true, 'row-4': true };
|
|
76
|
+
|
|
77
|
+
<DataTable
|
|
78
|
+
columns={columns}
|
|
79
|
+
data={rows}
|
|
80
|
+
height={500}
|
|
81
|
+
uniqueRowAccessor="id"
|
|
82
|
+
selection={selection}
|
|
83
|
+
onSelectionChange={handleSelectionChange}
|
|
84
|
+
disabledRows={disabledRows}
|
|
85
|
+
/>;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### noSelectionColumn — avoid unless explicitly accepted by product and a11y
|
|
89
|
+
|
|
90
|
+
**Stop before implementing this.** A request for `noSelectionColumn` is a signal that the user experience flow needs review, not implementation. Known issues:
|
|
91
|
+
|
|
92
|
+
- Selection is indicated only by row highlighting — not immediately discoverable by users unfamiliar with the pattern
|
|
93
|
+
- Row-click-to-select creates ambiguity when rows also have other interactive elements (buttons, links, editable cells)
|
|
94
|
+
- A11y compliance is not guaranteed for this interaction model — keyboard navigation, screen reader announcements, and focus management must be validated against the specific layout by an a11y SME before shipping
|
|
95
|
+
|
|
96
|
+
**When asked to implement this, push back explicitly:**
|
|
97
|
+
|
|
98
|
+
1. Surface the UX discoverability problem and request a design review of the selection flow
|
|
99
|
+
2. Flag the a11y risk and request SME consultation for the specific use case
|
|
100
|
+
3. Do not proceed until product has documented confirmation that they understand the trade-offs and accept responsibility for the UX and a11y outcomes
|
|
101
|
+
|
|
102
|
+
If after that review the product explicitly confirms they want to continue, the implementation is:
|
|
103
|
+
|
|
104
|
+
```jsx
|
|
105
|
+
// Only after explicit product sign-off and a11y SME review.
|
|
106
|
+
// noSelectionColumn + selection + onSelectionChange is sufficient.
|
|
107
|
+
// Row clicks already invoke onSelectionChange internally — onRowClick is NOT required
|
|
108
|
+
// and must not be used to duplicate selection logic (it fires before onSelectionChange
|
|
109
|
+
// and would be overwritten by the internal handler).
|
|
110
|
+
<DataTable
|
|
111
|
+
columns={columns}
|
|
112
|
+
data={rows}
|
|
113
|
+
height={500}
|
|
114
|
+
uniqueRowAccessor="id"
|
|
115
|
+
noSelectionColumn
|
|
116
|
+
selection={selection}
|
|
117
|
+
onSelectionChange={handleSelectionChange}
|
|
118
|
+
/>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Customizing the selection column
|
|
122
|
+
|
|
123
|
+
When you need to override the selection column's `Header`, `Cell`, or visual styling, spread the exported addon object and replace what you need. This is the only case where the column exports are used directly.
|
|
124
|
+
|
|
125
|
+
**Before customizing, understand what you are taking on.** The out-of-the-box selection column is built and tested as a unit — a11y attributes, keyboard interaction, indeterminate state, disabled row handling, shift-range selection, and screen reader announcements are all wired together internally. Overriding `Header` or `Cell` moves those responsibilities to the consumer. Dimsum no longer owns or guarantees the correctness of what you replace. If you override `Cell`, you own the checkbox implementation, its aria attributes, its disabled state, and its keyboard behavior. If you override `Header`, you own the select-all semantics and its accessible label. Test the customized column against your a11y requirements explicitly — do not assume the parts you did not touch remain correct in combination with the parts you did.
|
|
126
|
+
|
|
127
|
+
#### multi selection
|
|
128
|
+
|
|
129
|
+
```jsx
|
|
130
|
+
import { DataTable, multiSelectColumn } from '@elliemae/ds-data-table';
|
|
131
|
+
|
|
132
|
+
// Spread multiSelectColumn and override only what differs.
|
|
133
|
+
// The column ID from multiSelectColumn is preserved — DataTable's dedup logic
|
|
134
|
+
// sees it already in the array and skips auto-injection of a second one.
|
|
135
|
+
const CustomSelectColumn = {
|
|
136
|
+
...multiSelectColumn,
|
|
137
|
+
Header: () => <ScreenReaderOnly>Select row</ScreenReaderOnly>,
|
|
138
|
+
cellStyle: { position: 'sticky', left: 0, background: 'white', zIndex: 2 },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const columns = [CustomSelectColumn, { Header: 'Name', accessor: 'name' }];
|
|
142
|
+
|
|
143
|
+
// selection prop still drives the feature — no selectSingle needed for multi-select customization
|
|
144
|
+
<DataTable
|
|
145
|
+
columns={columns}
|
|
146
|
+
data={rows}
|
|
147
|
+
height={500}
|
|
148
|
+
uniqueRowAccessor="id"
|
|
149
|
+
selection={selection}
|
|
150
|
+
onSelectionChange={handleSelectionChange}
|
|
151
|
+
/>;
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### single selection
|
|
155
|
+
|
|
156
|
+
For single-select customization, you must also set `selectSingle={true}`. Without it, DataTable (seeing `selectSingle=false`) would auto-inject `multiSelectColumn` alongside your custom `singleSelectColumn` spread — two selection columns.
|
|
157
|
+
|
|
158
|
+
```jsx
|
|
159
|
+
import { DataTable, singleSelectColumn } from '@elliemae/ds-data-table';
|
|
160
|
+
|
|
161
|
+
const CustomSingleSelectColumn = {
|
|
162
|
+
...singleSelectColumn,
|
|
163
|
+
Header: () => <ScreenReaderOnly>Select row</ScreenReaderOnly>,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const columns = [CustomSingleSelectColumn, { Header: 'Name', accessor: 'name' }];
|
|
167
|
+
|
|
168
|
+
// selectSingle required here — prevents multiSelectColumn from also auto-injecting
|
|
169
|
+
<DataTable
|
|
170
|
+
columns={columns}
|
|
171
|
+
data={rows}
|
|
172
|
+
height={500}
|
|
173
|
+
uniqueRowAccessor="id"
|
|
174
|
+
selectSingle
|
|
175
|
+
selection={selection}
|
|
176
|
+
onSelectionChange={handleSelectionChange}
|
|
177
|
+
/>;
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Select-all intent — the second argument and what newSelection actually contains
|
|
181
|
+
|
|
182
|
+
When the header checkbox is clicked, `onSelectionChange` is called with:
|
|
183
|
+
|
|
184
|
+
- `newSelection` — a `Record<uid, boolean>` built from every row in the `data` prop (all rows and sub-rows recursively). DataTable has no knowledge of records outside what was passed to `data` — it only knows about what is currently displayed.
|
|
185
|
+
- `selectedControl === 'All'` — the literal string `'All'`, signaling the header checkbox triggered the change (not a specific row uid). This fires for **both** select-all and deselect-all gestures — the string does not indicate direction.
|
|
186
|
+
- Direction is determined by `newSelection`: populated map = selecting all, empty `{}` = deselecting all. There is no `false`-valued map for deselect.
|
|
187
|
+
|
|
188
|
+
The correct implementation scopes selection to what is currently displayed. DataTable has no knowledge of records beyond what was passed to `data` — and that boundary is intentional.
|
|
189
|
+
|
|
190
|
+
**Cross-page selection requires an external counter.** The DataTable header checkbox computes its checked/mixed/unchecked state from the currently displayed rows only. It cannot communicate "some other page has selections." When users need to accumulate selections across pages, the application must provide a visible counter contiguous to the table — above it, below it, or alongside it — that always reflects the true total. The header checkbox is not that place. The external counter is the authoritative state indicator.
|
|
191
|
+
|
|
192
|
+
This is the Dimsum recommendation, consistent with established industry practice. Other industry relevant design system like PatternFly documents the same pattern: _"This text always reflects the total number of items selected. If pagination is in use, it will reflect the items selected across all pages."_ — [patternfly.org/patterns/bulk-selection](https://www.patternfly.org/patterns/bulk-selection/)
|
|
193
|
+
|
|
194
|
+
**Critical implementation detail — "select all" does not merge:** when the header checkbox fires, `newSelection` is built from scratch starting at `{}`. It contains only the UIDs of the currently displayed rows. Individual row toggles spread the existing `selection` (the row handler does `{ ...selection, [uid]: newState }`), but the header checkbox does not. Dispatching `newSelection` directly on "select all" will erase previous pages' selections.
|
|
195
|
+
|
|
196
|
+
```jsx
|
|
197
|
+
const handleSelectionChange = useCallback(
|
|
198
|
+
(newSelection, selectedControl) => {
|
|
199
|
+
if (selectedControl === 'All') {
|
|
200
|
+
const isSelectingAll = Object.keys(newSelection).length > 0;
|
|
201
|
+
if (isSelectingAll) {
|
|
202
|
+
// Merge — add current page to existing selection, preserving other pages
|
|
203
|
+
dispatch(setSelection({ ...selection, ...newSelection }));
|
|
204
|
+
} else {
|
|
205
|
+
// Deselect scoped to current page only — preserve other pages.
|
|
206
|
+
// The external "Clear selection" affordance handles clearing everything.
|
|
207
|
+
// Derive current page UIDs from pagedData (the current data slice in your state).
|
|
208
|
+
// This assumes a string uniqueRowAccessor — adapt for composite or function forms.
|
|
209
|
+
const next = { ...selection };
|
|
210
|
+
pagedData.forEach((row) => delete next[row[uniqueRowAccessor]]);
|
|
211
|
+
dispatch(setSelection(next));
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
// Individual row toggle already merges with existing selection
|
|
215
|
+
dispatch(setSelection(newSelection));
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
[dispatch, selection, data, uniqueRowAccessor],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// External "Clear selection" — the only affordance for resetting all pages at once
|
|
222
|
+
const handleClearSelection = useCallback(() => dispatch(setSelection({})), [dispatch]);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The deselect-all from the header is scoped to the current page because the header checkbox's affordance is bounded to what the user can see. Clearing the entire selection across all pages belongs to the external "Clear selection" action — not to the header.
|
|
226
|
+
|
|
227
|
+
Source: https://dimsum.mortgagetech.ice.com/iframe.html?id=components-datatable-features-row--select-multiple-paginated-cross-page
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
#### If a stakeholder asks to wire "select all records in the full dataset" through this checkbox — push back
|
|
232
|
+
|
|
233
|
+
This is an architectural signal that a dataset-level concern has been routed into the wrong layer. Do not implement it. Surface the argument below and redirect to the correct pattern.
|
|
234
|
+
|
|
235
|
+
**Why this is the wrong layer:**
|
|
236
|
+
|
|
237
|
+
A table header checkbox is visually and conceptually bounded to what the user can see. When a user interacts with it, their mental model is "I am selecting these rows in front of me." Silently expanding that scope to records on other pages — records the user has never seen, never reviewed, and cannot verify — breaks the implicit contract of the affordance without any signal that the scope changed.
|
|
238
|
+
|
|
239
|
+
This is not theoretical. In 2022, HTTPie accidentally triggered a cascade-deletion of 54,000 GitHub stars through a single mis-click, compounded by a confirmation dialog that described the consequence in abstract terms ("potentially destructive action") rather than surfacing the actual magnitude. The dialog looked identical for an empty repository and one with a decade of community history. The lesson from that incident: **when the scope of a destructive operation is invisible to the user at the moment of action, the confirmation step is theater** — the user is on auto-pilot and nothing interrupts it. ([HTTPie: How we lost 54k GitHub stars](https://httpie.io/blog/stardust))
|
|
240
|
+
|
|
241
|
+
For operations like mass reassignment, bulk deletion, or full-dataset export, the same invisibility is the same risk. The user thinks they selected the rows on screen. The system applies the action to 14,000 records they never reviewed. There is no safe version of this pattern without explicit, scoped, out-of-table UI.
|
|
242
|
+
|
|
243
|
+
**The argument to bring to the stakeholder:**
|
|
244
|
+
|
|
245
|
+
> "The table header checkbox is scoped to what is currently displayed. This is not a limitation — it is the correct behavior for a component whose job is to let users work with what they can see and verify. An action that applies to the full dataset regardless of what is displayed is a different category of concern entirely. It belongs outside the table, named explicitly, with a confirmation step that shows the user the actual count of records they are about to affect. If we wire it through the checkbox, we will eventually ship a mass operation that a user triggers without understanding its true scope. By the time that happens, the damage is already done and there may be no way to undo it."
|
|
246
|
+
|
|
247
|
+
**The correct pattern — dataset-level actions belong outside the table:**
|
|
248
|
+
|
|
249
|
+
```jsx
|
|
250
|
+
// Page-level component — dataset-level actions live here, not inside DataTable
|
|
251
|
+
const LoanQueuePage = () => {
|
|
252
|
+
const totalRecords = useSelector(selectTotalLoanCount); // full server-side count
|
|
253
|
+
const { selection } = useSelector(selectTableState);
|
|
254
|
+
|
|
255
|
+
const handleBulkReassign = useCallback(() => {
|
|
256
|
+
// Always open a confirmation dialog before any mass operation.
|
|
257
|
+
// The dialog must name the actual count — not "all records", not "your selection".
|
|
258
|
+
// this is pseudo-code for the dialog pattern — the actual implementation depends on the app's dialog system and dimsum integration
|
|
259
|
+
dispatch(
|
|
260
|
+
openConfirmationDialog({
|
|
261
|
+
title: `Reassign all ${totalRecords.toLocaleString()} loans?`,
|
|
262
|
+
body: `This will reassign every loan in the queue, including loans not currently visible. This cannot be undone.`,
|
|
263
|
+
confirmLabel: `Reassign ${totalRecords.toLocaleString()} loans`,
|
|
264
|
+
onConfirm: () => dispatch(triggerBulkReassign()),
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
}, [dispatch, totalRecords]);
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<div>
|
|
271
|
+
{/* Dataset-level CTA — scoped explicitly, named, lives outside the table */}
|
|
272
|
+
<Button onClick={handleBulkReassign}>Reassign all {totalRecords.toLocaleString()} loans</Button>
|
|
273
|
+
|
|
274
|
+
{/* Table selection is scoped to currently displayed rows only */}
|
|
275
|
+
<DataTable
|
|
276
|
+
columns={columns}
|
|
277
|
+
data={pagedData}
|
|
278
|
+
height={500}
|
|
279
|
+
uniqueRowAccessor="id"
|
|
280
|
+
selection={selection}
|
|
281
|
+
onSelectionChange={handleSelectionChange}
|
|
282
|
+
/>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
};
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Confirmation dialogs for mass operations are not optional — and generic ones are nearly as bad as none.**
|
|
289
|
+
|
|
290
|
+
Any action that applies to records the user has not individually reviewed requires a confirmation step. That step must:
|
|
291
|
+
|
|
292
|
+
- Name the action specifically — not "are you sure?" or "this action cannot be undone"
|
|
293
|
+
- Show the actual count as a number (E.g. `14,823 loans`), not abstract language (`all records`, `your selection`)
|
|
294
|
+
- Make the scope unambiguous — if there is any possibility the user thinks the scope is smaller than it is, the dialog must correct that explicitly
|
|
295
|
+
- Scale to severity — a quiet inline confirmation is appropriate for small counts; a prominent modal with an explicit confirmation label is required for large or irreversible operations
|
|
296
|
+
|
|
297
|
+
A confirmation dialog that says "This action cannot be undone. Continue?" applied to a mass operation is the same failure as GitHub's dialog — it describes consequence abstractly without surfacing magnitude. The user reads it, sees nothing specific to their situation, and clicks through. That is not user confirmation. That is legal cover.
|
|
298
|
+
|
|
299
|
+
## Common Mistakes
|
|
300
|
+
|
|
301
|
+
### CRITICAL Selection without uniqueRowAccessor causes silent wrong-row mapping
|
|
302
|
+
|
|
303
|
+
Wrong:
|
|
304
|
+
|
|
305
|
+
```jsx
|
|
306
|
+
<DataTable columns={columns} data={rows} height={500} selection={selection} onSelectionChange={setSelection} />
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Correct:
|
|
310
|
+
|
|
311
|
+
```jsx
|
|
312
|
+
<DataTable
|
|
313
|
+
columns={columns}
|
|
314
|
+
data={rows}
|
|
315
|
+
height={500}
|
|
316
|
+
uniqueRowAccessor="id"
|
|
317
|
+
selection={selection}
|
|
318
|
+
onSelectionChange={handleSelectionChange}
|
|
319
|
+
/>
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Without `uniqueRowAccessor`, the `selection` record cannot be reliably mapped to rows — selections visually appear to work but apply to wrong rows or break on data update.
|
|
323
|
+
|
|
324
|
+
Source: `@elliemae/ds-data-table:dist/types/react-desc-prop-types.d.ts`
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
### HIGH Enabling selection without both selection state and onSelectionChange
|
|
329
|
+
|
|
330
|
+
Wrong:
|
|
331
|
+
|
|
332
|
+
```jsx
|
|
333
|
+
// Missing the controlled pair — selection interactions have no effect
|
|
334
|
+
<DataTable columns={columns} data={rows} height={500} uniqueRowAccessor="id" selection={selection} />
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Correct:
|
|
338
|
+
|
|
339
|
+
```jsx
|
|
340
|
+
<DataTable
|
|
341
|
+
columns={columns}
|
|
342
|
+
data={rows}
|
|
343
|
+
height={500}
|
|
344
|
+
uniqueRowAccessor="id"
|
|
345
|
+
selection={selection}
|
|
346
|
+
onSelectionChange={handleSelectionChange}
|
|
347
|
+
/>
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`selection` and `onSelectionChange` must both be provided. Selections appear to work visually but state is not captured or persisted without the handler.
|
|
351
|
+
|
|
352
|
+
Source: https://dimsum.mortgagetech.ice.com/iframe.html?id=components-datatable-features-row--select-multiple
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
### MEDIUM Custom selection column not in leading position
|
|
357
|
+
|
|
358
|
+
Applies to the customization path only — when spreading a selection addon into the columns array manually.
|
|
359
|
+
|
|
360
|
+
Wrong:
|
|
361
|
+
|
|
362
|
+
```jsx
|
|
363
|
+
const columns = [
|
|
364
|
+
{ Header: 'Name', accessor: 'name' },
|
|
365
|
+
{ ...multiSelectColumn, cellStyle: { position: 'sticky', left: 0 } }, // trailing position
|
|
366
|
+
];
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Correct:
|
|
370
|
+
|
|
371
|
+
```jsx
|
|
372
|
+
const columns = [
|
|
373
|
+
{ ...multiSelectColumn, cellStyle: { position: 'sticky', left: 0 } }, // leading position
|
|
374
|
+
{ Header: 'Name', accessor: 'name' },
|
|
375
|
+
];
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
The auto-injected column always prepends. A manually placed selection column in a trailing position is visually inconsistent and breaks the expected tab order through the row.
|
|
379
|
+
|
|
380
|
+
Source: https://dimsum.mortgagetech.ice.com/iframe.html?id=components-datatable-features-row--select-multiple
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
### HIGH Treating newSelection from select-all as the complete dataset selection when using AJAX pagination
|
|
385
|
+
|
|
386
|
+
Wrong:
|
|
387
|
+
|
|
388
|
+
```jsx
|
|
389
|
+
// With AJAX pagination — data holds only the current page
|
|
390
|
+
const handleSelectionChange = useCallback(
|
|
391
|
+
(newSelection) => {
|
|
392
|
+
dispatch(setSelection(newSelection)); // select all → selects current page only, silently
|
|
393
|
+
},
|
|
394
|
+
[dispatch],
|
|
395
|
+
);
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Correct:
|
|
399
|
+
|
|
400
|
+
```jsx
|
|
401
|
+
const handleSelectionChange = useCallback(
|
|
402
|
+
(newSelection, selectedControl) => {
|
|
403
|
+
if (selectedControl === 'All') {
|
|
404
|
+
const isSelectingAll = Object.keys(newSelection).length > 0;
|
|
405
|
+
if (isSelectingAll) {
|
|
406
|
+
// Merge — preserve selections from other pages
|
|
407
|
+
dispatch(setSelection({ ...selection, ...newSelection }));
|
|
408
|
+
} else {
|
|
409
|
+
// Deselect scoped to current page — preserve other pages
|
|
410
|
+
const next = { ...selection };
|
|
411
|
+
pagedData.forEach((row) => delete next[row.id]); // adapt to your uniqueRowAccessor
|
|
412
|
+
dispatch(setSelection(next));
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
dispatch(setSelection(newSelection));
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
[dispatch, selection, pagedData],
|
|
419
|
+
);
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
When `multiSelectColumn` builds `newSelection` for a select-all, it iterates over `allDataFlattened` — the rows currently in memory. With AJAX pagination, that is only the current page. Dispatching `newSelection` directly overwrites any selections the user accumulated on previous pages. The external selection counter (see Core Patterns above) is required to make the true cross-page total visible to the user.
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
### HIGH Non-primitive selection props not memoized cause unnecessary re-renders
|
|
427
|
+
|
|
428
|
+
Every non-primitive prop passed to DataTable must be a stable reference. For selection, the props that require memoization are:
|
|
429
|
+
|
|
430
|
+
| Prop | Type | Memoization |
|
|
431
|
+
| --------------------------------------- | -------- | ------------------------------- |
|
|
432
|
+
| `columns` (with addon column prepended) | array | `useMemo` |
|
|
433
|
+
| `onSelectionChange` | callback | `useCallback` |
|
|
434
|
+
| `disabledRows` | object | `useMemo` if constructed inline |
|
|
435
|
+
|
|
436
|
+
Non-stable references cause DataTable to re-render on every parent render cycle regardless of whether selection state actually changed.
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
See also: ds-data-table-setup/SKILL.md — uniqueRowAccessor configuration
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## When this isn't enough
|
|
445
|
+
|
|
446
|
+
If the documented patterns cannot satisfy the requirement, contact the Dimsum team:
|
|
447
|
+
|
|
448
|
+
**ICE internal:** Microsoft Teams — Dimsum channel (informal) / Jira Dimsum board (formal)
|
|
449
|
+
**Partners:** Your organization's Dimsum point of contact
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ds-data-table-setup
|
|
3
|
+
description: >
|
|
4
|
+
First-time setup of @elliemae/ds-data-table. Required props: columns, data, height (px only —
|
|
5
|
+
non-px silently defeats virtualization). uniqueRowAccessor for any stateful row
|
|
6
|
+
interaction. dsDataTableTableWrapper aria-label for a11y. domIdAffix when multiple DataTable
|
|
7
|
+
instances coexist on a page. State management: identify project's Redux/RTK/Zustand/Jotai
|
|
8
|
+
system before wiring — never useState for DataTable state.
|
|
9
|
+
type: core
|
|
10
|
+
library: ds-data-table
|
|
11
|
+
library_version: '3.60.0'
|
|
12
|
+
sources:
|
|
13
|
+
- '@elliemae/ds-data-table:dist/types/react-desc-prop-types.d.ts'
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
```jsx
|
|
19
|
+
import { DataTable } from '@elliemae/ds-data-table';
|
|
20
|
+
|
|
21
|
+
const columns = [
|
|
22
|
+
{ Header: 'Name', accessor: 'name' },
|
|
23
|
+
{ Header: 'Position', accessor: 'position' },
|
|
24
|
+
{ Header: 'Country', accessor: 'country' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
<DataTable
|
|
28
|
+
columns={columns}
|
|
29
|
+
data={rows}
|
|
30
|
+
height={500}
|
|
31
|
+
uniqueRowAccessor="id"
|
|
32
|
+
dsDataTableTableWrapper={{ 'aria-label': 'Employees table' }}
|
|
33
|
+
/>;
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Core Patterns
|
|
37
|
+
|
|
38
|
+
### State management — identify the project's system before writing any state
|
|
39
|
+
|
|
40
|
+
Before wiring any DataTable state, identify the project's state manager. Create the appropriate slice, store, or atoms:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
// Redux Toolkit — adapt to your project's system (Zustand, Jotai, Context, etc.)
|
|
44
|
+
import { createSlice } from '@reduxjs/toolkit';
|
|
45
|
+
|
|
46
|
+
const loansTableSlice = createSlice({
|
|
47
|
+
name: 'loansTable',
|
|
48
|
+
initialState: { data: [], selection: {}, filters: [], expandedRows: {} },
|
|
49
|
+
reducers: {
|
|
50
|
+
setData: (state, { payload }) => {
|
|
51
|
+
state.data = payload;
|
|
52
|
+
},
|
|
53
|
+
setSelection: (state, { payload }) => {
|
|
54
|
+
state.selection = payload;
|
|
55
|
+
},
|
|
56
|
+
setFilters: (state, { payload }) => {
|
|
57
|
+
state.filters = payload;
|
|
58
|
+
},
|
|
59
|
+
setExpandedRows: (state, { payload }) => {
|
|
60
|
+
state.expandedRows = payload;
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Stories in the Dimsum repo use `useState` as a teaching simplification only. DataTable-driving state in production must live in the project's state manager.
|
|
67
|
+
|
|
68
|
+
All state updates in response to DataTable callbacks (`onSelectionChange`, `onFiltersChange`, `onColumnSort`, etc.) belong **inside the callback handler**, not in a `useEffect` watching the resulting state. `useEffect` is not a response mechanism for callback-driven events — it exists for synchronizing with things outside React's model (browser APIs, external subscriptions). Using it to respond to a callback adds an extra render cycle and separates cause from effect across the codebase. Every DataTable prop that exposes a callback is already the correct and complete site for all logic that needs to run when that event occurs.
|
|
69
|
+
|
|
70
|
+
Consumer apps with AJAX data loading will legitimately have async concerns — data fetching, pagination, filter-driven API calls — but these are also best addressed without bare `useEffect + fetch` chains. The correct approach depends on the project's existing async infrastructure:
|
|
71
|
+
|
|
72
|
+
- **Redux Toolkit (RTK Query)** — define API endpoints as queries/mutations; the DataTable callback dispatches a query with the new params, RTK Query handles caching, loading state, and re-fetching. No `useEffect` needed.
|
|
73
|
+
- **TanStack React Query** — same model: the callback updates query params in state, the query key changes, React Query re-fetches automatically. The component re-renders with fresh data without a `useEffect`.
|
|
74
|
+
- **Redux Saga / Redux Observable** — the callback dispatches an action, the saga/epic intercepts it and handles the async flow. The component never manages async lifecycle directly.
|
|
75
|
+
|
|
76
|
+
In all of these, the DataTable callback dispatches an intent or updates a query key — the async infrastructure reacts to that, not to a `useEffect` watching derived state. If the project's existing infrastructure doesn't offer a clean path for a specific case, identify it explicitly and align with the team before reaching for `useEffect` as a shortcut. A `useEffect` that is genuinely the right tool should be a deliberate, documented decision — not a default.
|
|
77
|
+
|
|
78
|
+
### uniqueRowAccessor — three valid forms
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
// Single column key
|
|
82
|
+
uniqueRowAccessor="id"
|
|
83
|
+
|
|
84
|
+
// Composite key
|
|
85
|
+
uniqueRowAccessor={['loanId', 'borrowerId']}
|
|
86
|
+
|
|
87
|
+
// Function
|
|
88
|
+
uniqueRowAccessor={(row) => `${row.loanId}-${row.borrowerId}`}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Required for selection, drag & drop, and expandable rows.
|
|
92
|
+
|
|
93
|
+
### Multiple DataTable instances on the same page
|
|
94
|
+
|
|
95
|
+
```jsx
|
|
96
|
+
// domIdAffix is used to construct stable, predictable DOM IDs for aria relationships:
|
|
97
|
+
// aria-controls on the header checkbox, aria-labelledby on editable cells, inputID/id
|
|
98
|
+
// pairs on filter inputs. Without an explicit value, IDs are random per mount —
|
|
99
|
+
// external aria references will break on remount.
|
|
100
|
+
<DataTable columns={loanCols} data={loans} height={400} domIdAffix="loans-table" />
|
|
101
|
+
<DataTable columns={borrowerCols} data={borrowers} height={400} domIdAffix="borrowers-table" />
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Common Mistakes
|
|
105
|
+
|
|
106
|
+
### CRITICAL Omitting uniqueRowAccessor with selection, expand, or drag & drop
|
|
107
|
+
|
|
108
|
+
Wrong:
|
|
109
|
+
|
|
110
|
+
```jsx
|
|
111
|
+
<DataTable columns={columns} data={rows} height={500} selection={selection} onSelectionChange={setSelection} />
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Correct:
|
|
115
|
+
|
|
116
|
+
```jsx
|
|
117
|
+
<DataTable
|
|
118
|
+
columns={columns}
|
|
119
|
+
data={rows}
|
|
120
|
+
height={500}
|
|
121
|
+
uniqueRowAccessor="id"
|
|
122
|
+
selection={selection}
|
|
123
|
+
onSelectionChange={setSelection}
|
|
124
|
+
/>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Without `uniqueRowAccessor`, row identity is unstable — selection, expand state, and drag & drop silently map to wrong rows.
|
|
128
|
+
|
|
129
|
+
Source: `@elliemae/ds-data-table:dist/types/react-desc-prop-types.d.ts`
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
### HIGH Using height in % or vh silently defeats virtualization
|
|
134
|
+
|
|
135
|
+
Wrong:
|
|
136
|
+
|
|
137
|
+
```jsx
|
|
138
|
+
<DataTable columns={columns} data={rows} height="100%" />
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Correct:
|
|
142
|
+
|
|
143
|
+
```jsx
|
|
144
|
+
<DataTable columns={columns} data={rows} height={500} />
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
DataTable uses a virtual scroll container that measures its own rendered height to calculate which rows to render. When `height` is a percentage and the parent has no defined height, that container resolves to 0px. The virtualizer measures 0px, treats the entire dataset as visible, and renders all rows to the DOM simultaneously. The internal scrollbar disappears — the page scroll takes over instead. With large datasets this silently causes serious performance degradation. Always pass a numeric pixel value — it is self-contained and does not depend on parent container layout.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
### HIGH Managing DataTable state with local useState instead of the project state manager
|
|
152
|
+
|
|
153
|
+
Wrong:
|
|
154
|
+
|
|
155
|
+
```jsx
|
|
156
|
+
const [selection, setSelection] = useState({});
|
|
157
|
+
const [filters, setFilters] = useState([]);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Correct:
|
|
161
|
+
|
|
162
|
+
```jsx
|
|
163
|
+
// Create atoms/slice/store in the project's state manager first
|
|
164
|
+
const { selection, filters } = useSelector(selectLoansTableState);
|
|
165
|
+
const dispatch = useDispatch();
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`useState` for DataTable state leads to maintenance chaos, excessive `useEffect` syncing, and divergence between state managers and local state.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
### MEDIUM Multiple DataTable instances without explicit domIdAffix
|
|
173
|
+
|
|
174
|
+
Wrong:
|
|
175
|
+
|
|
176
|
+
```jsx
|
|
177
|
+
<DataTable columns={loanCols} data={loans} height={400} />
|
|
178
|
+
<DataTable columns={borrowerCols} data={borrowers} height={400} />
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Correct:
|
|
182
|
+
|
|
183
|
+
```jsx
|
|
184
|
+
<DataTable columns={loanCols} data={loans} height={400} domIdAffix="loans-table" />
|
|
185
|
+
<DataTable columns={borrowerCols} data={borrowers} height={400} domIdAffix="borrowers-table" />
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`domIdAffix` is used internally to construct DOM IDs for aria relationships — `aria-controls` on the header checkbox (pointing to individual row checkboxes), `aria-labelledby`/`id` pairs on editable cells, and `inputID`/`id` pairs on filter inputs. Without an explicit value the suffix is a random `uid(8)` per mount — IDs are unpredictable and any external aria reference built against them will break when the component remounts.
|
|
189
|
+
|
|
190
|
+
Source: https://dimsum.mortgagetech.ice.com/iframe.html?id=components-datatable-advanced--prefixed-ids-for-multiple-tables-in-same-pages
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
### HIGH Missing dsDataTableTableWrapper aria-label
|
|
195
|
+
|
|
196
|
+
Wrong:
|
|
197
|
+
|
|
198
|
+
```jsx
|
|
199
|
+
<DataTable columns={columns} data={rows} height={500} />
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Correct:
|
|
203
|
+
|
|
204
|
+
```jsx
|
|
205
|
+
<DataTable
|
|
206
|
+
columns={columns}
|
|
207
|
+
data={rows}
|
|
208
|
+
height={500}
|
|
209
|
+
dsDataTableTableWrapper={{ 'aria-label': 'Loan applications table' }}
|
|
210
|
+
/>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Without `aria-label`, the table has no accessible name. WCAG 4.1.2 (Name, Role, Value) requires tables to have an accessible name — omitting it is a VPAT failure.
|
|
214
|
+
|
|
215
|
+
Source: `@elliemae/ds-data-table:dist/types/react-desc-prop-types.d.ts`
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
See also: ds-data-table-columns/SKILL.md — column object configuration
|
|
220
|
+
See also: ds-data-table-feedback/SKILL.md — height constraint also affects skeleton
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## When this isn't enough
|
|
225
|
+
|
|
226
|
+
If the documented patterns cannot satisfy the requirement, contact the Dimsum team:
|
|
227
|
+
|
|
228
|
+
**ICE internal:** Microsoft Teams — Dimsum channel (informal) / Jira Dimsum board (formal)
|
|
229
|
+
**Partners:** Your organization's Dimsum point of contact
|