@kinetixui/ui 0.18.0 → 0.19.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/dist/index.d.ts +16 -1
- package/dist/index.js +80 -3
- package/package.json +2 -2
- package/src/components/data-grid.tsx +109 -1
- package/src/components-keyboard.test.tsx +154 -3
- package/src/stories/DataGrid.stories.tsx +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -1621,6 +1621,13 @@ interface DataGridColumn<TData> {
|
|
|
1621
1621
|
editable?: boolean;
|
|
1622
1622
|
onCellEdit?: (row: TData, rowIndex: number, value: string) => void;
|
|
1623
1623
|
}
|
|
1624
|
+
/** A rectangular range of cells, in display order (after sorting and column reordering). */
|
|
1625
|
+
interface DataGridSelection {
|
|
1626
|
+
/** first and last selected row index, inclusive, into the *displayed* (sorted) rows */
|
|
1627
|
+
rows: [number, number];
|
|
1628
|
+
/** ids of the selected columns, in display order */
|
|
1629
|
+
columns: string[];
|
|
1630
|
+
}
|
|
1624
1631
|
interface DataGridProps<TData> {
|
|
1625
1632
|
columns: DataGridColumn<TData>[];
|
|
1626
1633
|
data: TData[];
|
|
@@ -1630,6 +1637,14 @@ interface DataGridProps<TData> {
|
|
|
1630
1637
|
overscan?: number;
|
|
1631
1638
|
getRowId?: (row: TData, index: number) => React$1.Key;
|
|
1632
1639
|
className?: string;
|
|
1640
|
+
/**
|
|
1641
|
+
* Opt in to range selection: Shift+arrows / Shift+click extend a rectangle from the anchor cell,
|
|
1642
|
+
* Ctrl/Cmd+A selects everything, Ctrl/Cmd+C copies the range as tab-separated text (each
|
|
1643
|
+
* column's `value()`; empty where a column has none), Esc clears. Sets aria-multiselectable.
|
|
1644
|
+
*/
|
|
1645
|
+
selectable?: boolean;
|
|
1646
|
+
/** fires when the selected range changes; `null` when it is cleared */
|
|
1647
|
+
onSelectionChange?: (selection: DataGridSelection | null) => void;
|
|
1633
1648
|
}
|
|
1634
1649
|
/**
|
|
1635
1650
|
* DataGrid — the "product on its own" the `COMPONENT-ADDITIONS.md` Tier 3
|
|
@@ -1647,7 +1662,7 @@ interface DataGridProps<TData> {
|
|
|
1647
1662
|
* flexibility, at the cost of the native table-navigation semantics
|
|
1648
1663
|
* `DataTable` still gets for free.
|
|
1649
1664
|
*/
|
|
1650
|
-
declare function DataGrid<TData>({ columns, data, height, rowHeight, overscan, getRowId, className, }: DataGridProps<TData>): React$1.JSX.Element;
|
|
1665
|
+
declare function DataGrid<TData>({ columns, data, height, rowHeight, overscan, getRowId, className, selectable, onSelectionChange, }: DataGridProps<TData>): React$1.JSX.Element;
|
|
1651
1666
|
|
|
1652
1667
|
interface JsonViewerProps extends Omit<React$1.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
1653
1668
|
data: unknown;
|
package/dist/index.js
CHANGED
|
@@ -5029,7 +5029,9 @@ function DataGrid({
|
|
|
5029
5029
|
rowHeight = DEFAULT_ROW_HEIGHT,
|
|
5030
5030
|
overscan = 6,
|
|
5031
5031
|
getRowId,
|
|
5032
|
-
className
|
|
5032
|
+
className,
|
|
5033
|
+
selectable = false,
|
|
5034
|
+
onSelectionChange
|
|
5033
5035
|
}) {
|
|
5034
5036
|
const columnById = React83.useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
|
|
5035
5037
|
const [order, setOrder] = React83.useState(() => columns.map((c) => c.id));
|
|
@@ -5145,12 +5147,57 @@ function DataGrid({
|
|
|
5145
5147
|
const [active, setActive] = React83.useState({ row: -1, columnId: columns[0]?.id ?? "" });
|
|
5146
5148
|
const pendingFocusRef = React83.useRef(null);
|
|
5147
5149
|
const [announcement, setAnnouncement] = React83.useState("");
|
|
5150
|
+
const [sel, setSel] = React83.useState(null);
|
|
5151
|
+
const onSelectionChangeRef = React83.useRef(onSelectionChange);
|
|
5152
|
+
onSelectionChangeRef.current = onSelectionChange;
|
|
5153
|
+
const lastEmittedRef = React83.useRef("null");
|
|
5148
5154
|
const rowCount = sortedData.length;
|
|
5149
5155
|
const colIds = renderedColumns.map((c) => c.id);
|
|
5150
5156
|
const activeCol = colIds.includes(active.columnId) ? active.columnId : colIds[0] ?? "";
|
|
5151
5157
|
const activeRow = Math.min(active.row, rowCount - 1);
|
|
5152
5158
|
const activeRendered = activeRow < 0 || activeRow >= startIndex && activeRow < endIndex;
|
|
5153
5159
|
const tabStop = { row: activeRendered ? activeRow : -1, columnId: activeCol };
|
|
5160
|
+
const colKey = colIds.join("|");
|
|
5161
|
+
const range = React83.useMemo(() => {
|
|
5162
|
+
if (!selectable || !sel) return null;
|
|
5163
|
+
const a = colIds.indexOf(sel.anchor.columnId);
|
|
5164
|
+
const b = colIds.indexOf(sel.end.columnId);
|
|
5165
|
+
if (a < 0 || b < 0) return null;
|
|
5166
|
+
return { r0: Math.min(sel.anchor.row, sel.end.row), r1: Math.max(sel.anchor.row, sel.end.row), c0: Math.min(a, b), c1: Math.max(a, b) };
|
|
5167
|
+
}, [selectable, sel, colKey]);
|
|
5168
|
+
React83.useEffect(() => setSel(null), [sort, rowCount]);
|
|
5169
|
+
React83.useEffect(() => {
|
|
5170
|
+
if (!selectable) return;
|
|
5171
|
+
const value = range ? { rows: [range.r0, range.r1], columns: colIds.slice(range.c0, range.c1 + 1) } : null;
|
|
5172
|
+
const key = JSON.stringify(value);
|
|
5173
|
+
if (key === lastEmittedRef.current) return;
|
|
5174
|
+
lastEmittedRef.current = key;
|
|
5175
|
+
onSelectionChangeRef.current?.(value);
|
|
5176
|
+
setAnnouncement(value ? `${(range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1)} cells selected` : "Selection cleared");
|
|
5177
|
+
}, [selectable, range]);
|
|
5178
|
+
function extendSelection(fromRow, fromCol, toRow, toCol) {
|
|
5179
|
+
const anchor = sel?.anchor ?? { row: fromRow, columnId: fromCol };
|
|
5180
|
+
const end = { row: Math.max(0, Math.min(toRow, rowCount - 1)), columnId: toCol };
|
|
5181
|
+
setSel({ anchor, end });
|
|
5182
|
+
moveTo(end.row, end.columnId);
|
|
5183
|
+
}
|
|
5184
|
+
function selectAll() {
|
|
5185
|
+
if (rowCount === 0 || colIds.length === 0) return;
|
|
5186
|
+
setSel({ anchor: { row: 0, columnId: colIds[0] }, end: { row: rowCount - 1, columnId: colIds[colIds.length - 1] } });
|
|
5187
|
+
}
|
|
5188
|
+
function copySelection() {
|
|
5189
|
+
if (!range) return;
|
|
5190
|
+
const ids = colIds.slice(range.c0, range.c1 + 1);
|
|
5191
|
+
const text = sortedData.slice(range.r0, range.r1 + 1).map((r) => ids.map((id) => {
|
|
5192
|
+
const c = columnById.get(id);
|
|
5193
|
+
return c?.value ? String(c.value(r)) : "";
|
|
5194
|
+
}).join(" ")).join("\n");
|
|
5195
|
+
try {
|
|
5196
|
+
void navigator.clipboard?.writeText(text);
|
|
5197
|
+
setAnnouncement(`Copied ${(range.r1 - range.r0 + 1) * ids.length} cells`);
|
|
5198
|
+
} catch {
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5154
5201
|
const pageRows = Math.max(1, Math.floor((viewportHeight - HEADER_HEIGHT) / rowHeight) - 1);
|
|
5155
5202
|
const labelOf = (c) => typeof c.header === "string" ? c.header : c.id;
|
|
5156
5203
|
function ensureRowVisible(row) {
|
|
@@ -5224,9 +5271,28 @@ function DataGrid({
|
|
|
5224
5271
|
else resizeColumn(column, horizontal * 10);
|
|
5225
5272
|
return;
|
|
5226
5273
|
}
|
|
5274
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
5275
|
+
if (selectable && row >= 0) {
|
|
5276
|
+
if (mod && e.key.toLowerCase() === "a") {
|
|
5277
|
+
e.preventDefault();
|
|
5278
|
+
return selectAll();
|
|
5279
|
+
}
|
|
5280
|
+
if (mod && e.key.toLowerCase() === "c" && range) {
|
|
5281
|
+
e.preventDefault();
|
|
5282
|
+
return copySelection();
|
|
5283
|
+
}
|
|
5284
|
+
if (e.key === "Escape" && sel) {
|
|
5285
|
+
e.preventDefault();
|
|
5286
|
+
return setSel(null);
|
|
5287
|
+
}
|
|
5288
|
+
}
|
|
5289
|
+
const extend = selectable && e.shiftKey && row >= 0;
|
|
5227
5290
|
const go = (r, i) => {
|
|
5228
5291
|
e.preventDefault();
|
|
5229
|
-
|
|
5292
|
+
const target = colIds[Math.max(0, Math.min(i, colIds.length - 1))];
|
|
5293
|
+
if (extend) return extendSelection(row, column.id, r, target);
|
|
5294
|
+
if (sel) setSel(null);
|
|
5295
|
+
moveTo(r, target);
|
|
5230
5296
|
};
|
|
5231
5297
|
switch (e.key) {
|
|
5232
5298
|
case "ArrowRight":
|
|
@@ -5270,6 +5336,7 @@ function DataGrid({
|
|
|
5270
5336
|
role: "grid",
|
|
5271
5337
|
"aria-rowcount": rowCount + 1,
|
|
5272
5338
|
"aria-colcount": colIds.length,
|
|
5339
|
+
"aria-multiselectable": selectable || void 0,
|
|
5273
5340
|
className: cn("relative overflow-auto rounded-md border font-sans text-sm", className),
|
|
5274
5341
|
style: { height },
|
|
5275
5342
|
onScroll: (e) => setScrollTop(e.currentTarget.scrollTop),
|
|
@@ -5346,19 +5413,29 @@ function DataGrid({
|
|
|
5346
5413
|
const width = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
|
|
5347
5414
|
const pinnedStyle = column.pinned === "left" ? { position: "sticky", left: leftOffsets[column.id], zIndex: 1 } : column.pinned === "right" ? { position: "sticky", right: rightOffsets[column.id], zIndex: 1 } : {};
|
|
5348
5415
|
const isEditing = editing?.rowIndex === rowIndex && editing.columnId === column.id;
|
|
5416
|
+
const selected = selectable && !!range && rowIndex >= range.r0 && rowIndex <= range.r1 && colIdx >= range.c0 && colIdx <= range.c1;
|
|
5349
5417
|
return /* @__PURE__ */ jsx89(
|
|
5350
5418
|
"div",
|
|
5351
5419
|
{
|
|
5352
5420
|
role: "gridcell",
|
|
5353
5421
|
"aria-colindex": colIdx + 1,
|
|
5422
|
+
"aria-selected": selectable ? selected : void 0,
|
|
5354
5423
|
"data-cell": `${rowIndex}:${column.id}`,
|
|
5424
|
+
onMouseDown: (e) => {
|
|
5425
|
+
if (!selectable || e.button !== 0) return;
|
|
5426
|
+
if (e.shiftKey) {
|
|
5427
|
+
e.preventDefault();
|
|
5428
|
+
extendSelection(activeRow >= 0 ? activeRow : rowIndex, activeCol, rowIndex, column.id);
|
|
5429
|
+
} else if (sel) setSel(null);
|
|
5430
|
+
},
|
|
5355
5431
|
onDoubleClick: () => column.editable && setEditing({ rowIndex, columnId: column.id }),
|
|
5356
5432
|
tabIndex: tabStop.row === rowIndex && tabStop.columnId === column.id ? 0 : -1,
|
|
5357
5433
|
onFocus: () => setActive({ row: rowIndex, columnId: column.id }),
|
|
5358
5434
|
onKeyDown: (e) => onNavKeyDown(e, rowIndex, column),
|
|
5359
5435
|
className: cn(
|
|
5360
5436
|
"flex shrink-0 items-center px-2 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
|
|
5361
|
-
column.pinned && "bg-background"
|
|
5437
|
+
column.pinned && "bg-background",
|
|
5438
|
+
selected && "bg-accent"
|
|
5362
5439
|
),
|
|
5363
5440
|
style: { width, ...pinnedStyle },
|
|
5364
5441
|
children: isEditing ? /* @__PURE__ */ jsx89(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kinetixui/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "KinetixUI React components — CVA + Radix + Tailwind on the KinetixUI token contract. Also distributed via the kinetixui CLI as a component registry.",
|
|
6
6
|
"keywords": [
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@dnd-kit/sortable": "^10.0.0",
|
|
45
45
|
"@dnd-kit/utilities": "^3.2.2",
|
|
46
46
|
"@hookform/resolvers": "^5.9.1",
|
|
47
|
-
"@kinetixui/tokens": "0.
|
|
47
|
+
"@kinetixui/tokens": "0.19.0",
|
|
48
48
|
"@radix-ui/react-accordion": "^1.2.20",
|
|
49
49
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
|
50
50
|
"@radix-ui/react-aspect-ratio": "^1.1.15",
|
|
@@ -24,6 +24,14 @@ export interface DataGridColumn<TData> {
|
|
|
24
24
|
onCellEdit?: (row: TData, rowIndex: number, value: string) => void;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** A rectangular range of cells, in display order (after sorting and column reordering). */
|
|
28
|
+
export interface DataGridSelection {
|
|
29
|
+
/** first and last selected row index, inclusive, into the *displayed* (sorted) rows */
|
|
30
|
+
rows: [number, number];
|
|
31
|
+
/** ids of the selected columns, in display order */
|
|
32
|
+
columns: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
export interface DataGridProps<TData> {
|
|
28
36
|
columns: DataGridColumn<TData>[];
|
|
29
37
|
data: TData[];
|
|
@@ -33,6 +41,14 @@ export interface DataGridProps<TData> {
|
|
|
33
41
|
overscan?: number;
|
|
34
42
|
getRowId?: (row: TData, index: number) => React.Key;
|
|
35
43
|
className?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Opt in to range selection: Shift+arrows / Shift+click extend a rectangle from the anchor cell,
|
|
46
|
+
* Ctrl/Cmd+A selects everything, Ctrl/Cmd+C copies the range as tab-separated text (each
|
|
47
|
+
* column's `value()`; empty where a column has none), Esc clears. Sets aria-multiselectable.
|
|
48
|
+
*/
|
|
49
|
+
selectable?: boolean;
|
|
50
|
+
/** fires when the selected range changes; `null` when it is cleared */
|
|
51
|
+
onSelectionChange?: (selection: DataGridSelection | null) => void;
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
type SortState = { columnId: string; direction: "asc" | "desc" } | null;
|
|
@@ -61,6 +77,8 @@ function DataGrid<TData>({
|
|
|
61
77
|
overscan = 6,
|
|
62
78
|
getRowId,
|
|
63
79
|
className,
|
|
80
|
+
selectable = false,
|
|
81
|
+
onSelectionChange,
|
|
64
82
|
}: DataGridProps<TData>) {
|
|
65
83
|
const columnById = React.useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
|
|
66
84
|
const [order, setOrder] = React.useState(() => columns.map((c) => c.id));
|
|
@@ -203,6 +221,13 @@ function DataGrid<TData>({
|
|
|
203
221
|
const pendingFocusRef = React.useRef<Pos | null>(null);
|
|
204
222
|
const [announcement, setAnnouncement] = React.useState("");
|
|
205
223
|
|
|
224
|
+
// ── range selection (opt-in) ──────────────────────────────────────────────────────────────
|
|
225
|
+
type Anchor = { row: number; columnId: string };
|
|
226
|
+
const [sel, setSel] = React.useState<{ anchor: Anchor; end: Anchor } | null>(null);
|
|
227
|
+
const onSelectionChangeRef = React.useRef(onSelectionChange);
|
|
228
|
+
onSelectionChangeRef.current = onSelectionChange;
|
|
229
|
+
const lastEmittedRef = React.useRef("null");
|
|
230
|
+
|
|
206
231
|
const rowCount = sortedData.length;
|
|
207
232
|
const colIds = renderedColumns.map((c) => c.id);
|
|
208
233
|
const activeCol = colIds.includes(active.columnId) ? active.columnId : (colIds[0] ?? "");
|
|
@@ -211,6 +236,57 @@ function DataGrid<TData>({
|
|
|
211
236
|
// the grid stays reachable by Tab
|
|
212
237
|
const activeRendered = activeRow < 0 || (activeRow >= startIndex && activeRow < endIndex);
|
|
213
238
|
const tabStop: Pos = { row: activeRendered ? activeRow : -1, columnId: activeCol };
|
|
239
|
+
const colKey = colIds.join("|");
|
|
240
|
+
const range = React.useMemo(() => {
|
|
241
|
+
if (!selectable || !sel) return null;
|
|
242
|
+
const a = colIds.indexOf(sel.anchor.columnId);
|
|
243
|
+
const b = colIds.indexOf(sel.end.columnId);
|
|
244
|
+
if (a < 0 || b < 0) return null;
|
|
245
|
+
return { r0: Math.min(sel.anchor.row, sel.end.row), r1: Math.max(sel.anchor.row, sel.end.row), c0: Math.min(a, b), c1: Math.max(a, b) };
|
|
246
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
247
|
+
}, [selectable, sel, colKey]);
|
|
248
|
+
|
|
249
|
+
// Selection refers to displayed rows, so a re-sort or a different row count invalidates it.
|
|
250
|
+
React.useEffect(() => setSel(null), [sort, rowCount]);
|
|
251
|
+
|
|
252
|
+
React.useEffect(() => {
|
|
253
|
+
if (!selectable) return;
|
|
254
|
+
const value = range ? { rows: [range.r0, range.r1] as [number, number], columns: colIds.slice(range.c0, range.c1 + 1) } : null;
|
|
255
|
+
const key = JSON.stringify(value);
|
|
256
|
+
if (key === lastEmittedRef.current) return;
|
|
257
|
+
lastEmittedRef.current = key;
|
|
258
|
+
onSelectionChangeRef.current?.(value);
|
|
259
|
+
setAnnouncement(value ? `${(range!.r1 - range!.r0 + 1) * (range!.c1 - range!.c0 + 1)} cells selected` : "Selection cleared");
|
|
260
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
261
|
+
}, [selectable, range]);
|
|
262
|
+
|
|
263
|
+
function extendSelection(fromRow: number, fromCol: string, toRow: number, toCol: string) {
|
|
264
|
+
const anchor = sel?.anchor ?? { row: fromRow, columnId: fromCol };
|
|
265
|
+
const end = { row: Math.max(0, Math.min(toRow, rowCount - 1)), columnId: toCol };
|
|
266
|
+
setSel({ anchor, end });
|
|
267
|
+
moveTo(end.row, end.columnId);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function selectAll() {
|
|
271
|
+
if (rowCount === 0 || colIds.length === 0) return;
|
|
272
|
+
setSel({ anchor: { row: 0, columnId: colIds[0]! }, end: { row: rowCount - 1, columnId: colIds[colIds.length - 1]! } });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function copySelection() {
|
|
276
|
+
if (!range) return;
|
|
277
|
+
const ids = colIds.slice(range.c0, range.c1 + 1);
|
|
278
|
+
const text = sortedData
|
|
279
|
+
.slice(range.r0, range.r1 + 1)
|
|
280
|
+
.map((r) => ids.map((id) => { const c = columnById.get(id); return c?.value ? String(c.value(r)) : ""; }).join("\t"))
|
|
281
|
+
.join("\n");
|
|
282
|
+
try {
|
|
283
|
+
void navigator.clipboard?.writeText(text);
|
|
284
|
+
setAnnouncement(`Copied ${(range.r1 - range.r0 + 1) * ids.length} cells`);
|
|
285
|
+
} catch {
|
|
286
|
+
/* clipboard unavailable (insecure context / permission) — nothing to announce */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
214
290
|
const pageRows = Math.max(1, Math.floor((viewportHeight - HEADER_HEIGHT) / rowHeight) - 1);
|
|
215
291
|
const labelOf = (c: DataGridColumn<TData>) => (typeof c.header === "string" ? c.header : c.id);
|
|
216
292
|
|
|
@@ -295,9 +371,28 @@ function DataGrid<TData>({
|
|
|
295
371
|
return;
|
|
296
372
|
}
|
|
297
373
|
|
|
374
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
375
|
+
if (selectable && row >= 0) {
|
|
376
|
+
if (mod && e.key.toLowerCase() === "a") {
|
|
377
|
+
e.preventDefault();
|
|
378
|
+
return selectAll();
|
|
379
|
+
}
|
|
380
|
+
if (mod && e.key.toLowerCase() === "c" && range) {
|
|
381
|
+
e.preventDefault();
|
|
382
|
+
return copySelection();
|
|
383
|
+
}
|
|
384
|
+
if (e.key === "Escape" && sel) {
|
|
385
|
+
e.preventDefault();
|
|
386
|
+
return setSel(null);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const extend = selectable && e.shiftKey && row >= 0;
|
|
298
390
|
const go = (r: number, i: number) => {
|
|
299
391
|
e.preventDefault();
|
|
300
|
-
|
|
392
|
+
const target = colIds[Math.max(0, Math.min(i, colIds.length - 1))]!;
|
|
393
|
+
if (extend) return extendSelection(row, column.id, r, target);
|
|
394
|
+
if (sel) setSel(null); // a plain move collapses the range
|
|
395
|
+
moveTo(r, target);
|
|
301
396
|
};
|
|
302
397
|
switch (e.key) {
|
|
303
398
|
case "ArrowRight":
|
|
@@ -341,6 +436,7 @@ function DataGrid<TData>({
|
|
|
341
436
|
role="grid"
|
|
342
437
|
aria-rowcount={rowCount + 1}
|
|
343
438
|
aria-colcount={colIds.length}
|
|
439
|
+
aria-multiselectable={selectable || undefined}
|
|
344
440
|
className={cn("relative overflow-auto rounded-md border font-sans text-sm", className)}
|
|
345
441
|
style={{ height }}
|
|
346
442
|
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
|
@@ -434,12 +530,23 @@ function DataGrid<TData>({
|
|
|
434
530
|
? { position: "sticky", right: rightOffsets[column.id], zIndex: 1 }
|
|
435
531
|
: {};
|
|
436
532
|
const isEditing = editing?.rowIndex === rowIndex && editing.columnId === column.id;
|
|
533
|
+
const selected = selectable && !!range && rowIndex >= range.r0 && rowIndex <= range.r1 && colIdx >= range.c0 && colIdx <= range.c1;
|
|
437
534
|
return (
|
|
438
535
|
<div
|
|
439
536
|
key={column.id}
|
|
440
537
|
role="gridcell"
|
|
441
538
|
aria-colindex={colIdx + 1}
|
|
539
|
+
aria-selected={selectable ? selected : undefined}
|
|
442
540
|
data-cell={`${rowIndex}:${column.id}`}
|
|
541
|
+
onMouseDown={(e) => {
|
|
542
|
+
if (!selectable || e.button !== 0) return;
|
|
543
|
+
if (e.shiftKey) {
|
|
544
|
+
// extend from the previously active cell (focus has not moved yet on mousedown) — even when it has
|
|
545
|
+
// since scrolled out of the rendered window
|
|
546
|
+
e.preventDefault();
|
|
547
|
+
extendSelection(activeRow >= 0 ? activeRow : rowIndex, activeCol, rowIndex, column.id);
|
|
548
|
+
} else if (sel) setSel(null);
|
|
549
|
+
}}
|
|
443
550
|
onDoubleClick={() => column.editable && setEditing({ rowIndex, columnId: column.id })}
|
|
444
551
|
// roving tabindex; Enter or F2 edits an editable cell (the spreadsheet convention)
|
|
445
552
|
tabIndex={tabStop.row === rowIndex && tabStop.columnId === column.id ? 0 : -1}
|
|
@@ -448,6 +555,7 @@ function DataGrid<TData>({
|
|
|
448
555
|
className={cn(
|
|
449
556
|
"flex shrink-0 items-center px-2 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
|
|
450
557
|
column.pinned && "bg-background",
|
|
558
|
+
selected && "bg-accent",
|
|
451
559
|
)}
|
|
452
560
|
style={{ width, ...pinnedStyle }}
|
|
453
561
|
>
|
|
@@ -6,7 +6,7 @@ import { Checkbox } from "./components/checkbox";
|
|
|
6
6
|
import { ColorPicker } from "./components/color-picker";
|
|
7
7
|
import { FileUpload } from "./components/file-upload";
|
|
8
8
|
import { List, ListItem } from "./components/list";
|
|
9
|
-
import { DataGrid, type DataGridColumn } from "./components/data-grid";
|
|
9
|
+
import { DataGrid, type DataGridColumn, type DataGridSelection } from "./components/data-grid";
|
|
10
10
|
import { KinetixDirectionProvider } from "./components/direction-provider";
|
|
11
11
|
import {
|
|
12
12
|
Dialog,
|
|
@@ -434,13 +434,30 @@ describe("DataGrid", () => {
|
|
|
434
434
|
{ id: 2, name: "Abe" },
|
|
435
435
|
{ id: 3, name: "Bea" },
|
|
436
436
|
];
|
|
437
|
-
function setup(
|
|
437
|
+
function setup(
|
|
438
|
+
opts: {
|
|
439
|
+
onCellEdit?: ReturnType<typeof vi.fn>;
|
|
440
|
+
data?: Row[];
|
|
441
|
+
wrapper?: (n: React.ReactNode) => React.ReactNode;
|
|
442
|
+
selectable?: boolean;
|
|
443
|
+
onSelectionChange?: (s: DataGridSelection | null) => void;
|
|
444
|
+
} = {},
|
|
445
|
+
) {
|
|
438
446
|
const onCellEdit = opts.onCellEdit ?? vi.fn();
|
|
439
447
|
const columns: DataGridColumn<Row>[] = [
|
|
440
448
|
{ id: "name", header: "Name", cell: (r) => r.name, value: (r) => r.name, sortable: true, editable: true, onCellEdit },
|
|
441
449
|
{ id: "id", header: "ID", cell: (r) => r.id, value: (r) => r.id, minWidth: 60, width: 100 },
|
|
442
450
|
];
|
|
443
|
-
const grid =
|
|
451
|
+
const grid = (
|
|
452
|
+
<DataGrid
|
|
453
|
+
columns={columns}
|
|
454
|
+
data={opts.data ?? rows}
|
|
455
|
+
height={200}
|
|
456
|
+
getRowId={(r) => r.id}
|
|
457
|
+
selectable={opts.selectable}
|
|
458
|
+
onSelectionChange={opts.onSelectionChange}
|
|
459
|
+
/>
|
|
460
|
+
);
|
|
444
461
|
render(
|
|
445
462
|
<>
|
|
446
463
|
{opts.wrapper ? opts.wrapper(grid) : grid}
|
|
@@ -612,6 +629,140 @@ describe("DataGrid", () => {
|
|
|
612
629
|
expect(header("Name")).toHaveFocus();
|
|
613
630
|
});
|
|
614
631
|
|
|
632
|
+
describe("range selection (opt-in)", () => {
|
|
633
|
+
const selectedCells = () => screen.getAllByRole("gridcell").filter((c) => c.getAttribute("aria-selected") === "true");
|
|
634
|
+
|
|
635
|
+
it("is off by default: no aria-multiselectable, no aria-selected, Shift+Arrow just moves", async () => {
|
|
636
|
+
const user = userEvent.setup();
|
|
637
|
+
setup();
|
|
638
|
+
expect(screen.getByRole("grid")).not.toHaveAttribute("aria-multiselectable");
|
|
639
|
+
expect(cell(0, 0)).not.toHaveAttribute("aria-selected");
|
|
640
|
+
await user.tab();
|
|
641
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{/Shift}");
|
|
642
|
+
expect(cell(1, 0)).toHaveFocus();
|
|
643
|
+
expect(cell(0, 0)).not.toHaveAttribute("aria-selected");
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("declares the grid multi-selectable and marks every cell selected=false until something is selected", async () => {
|
|
647
|
+
setup({ selectable: true });
|
|
648
|
+
expect(screen.getByRole("grid")).toHaveAttribute("aria-multiselectable", "true");
|
|
649
|
+
expect(cell(0, 0)).toHaveAttribute("aria-selected", "false");
|
|
650
|
+
expect(selectedCells()).toHaveLength(0);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("Shift+Arrow extends a rectangle from the anchor and reports it", async () => {
|
|
654
|
+
const user = userEvent.setup();
|
|
655
|
+
const onSelectionChange = vi.fn();
|
|
656
|
+
setup({ selectable: true, onSelectionChange });
|
|
657
|
+
await user.tab();
|
|
658
|
+
await user.keyboard("{ArrowDown}"); // Name, row 0 (anchor)
|
|
659
|
+
await user.keyboard("{Shift>}{ArrowDown}{ArrowRight}{/Shift}");
|
|
660
|
+
|
|
661
|
+
expect(selectedCells()).toHaveLength(4); // rows 0-1 x Name, ID
|
|
662
|
+
expect(cell(1, 1)).toHaveFocus();
|
|
663
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith({ rows: [0, 1], columns: ["name", "id"] });
|
|
664
|
+
expect(screen.getByRole("status")).toHaveTextContent("4 cells selected");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("shrinks when Shift moves back toward the anchor", async () => {
|
|
668
|
+
const user = userEvent.setup();
|
|
669
|
+
setup({ selectable: true });
|
|
670
|
+
await user.tab();
|
|
671
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{ArrowDown}{/Shift}");
|
|
672
|
+
expect(selectedCells()).toHaveLength(3);
|
|
673
|
+
await user.keyboard("{Shift>}{ArrowUp}{/Shift}");
|
|
674
|
+
expect(selectedCells()).toHaveLength(2);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("a plain arrow key collapses the selection", async () => {
|
|
678
|
+
const user = userEvent.setup();
|
|
679
|
+
const onSelectionChange = vi.fn();
|
|
680
|
+
setup({ selectable: true, onSelectionChange });
|
|
681
|
+
await user.tab();
|
|
682
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{/Shift}");
|
|
683
|
+
expect(selectedCells().length).toBeGreaterThan(0);
|
|
684
|
+
await user.keyboard("{ArrowDown}");
|
|
685
|
+
expect(selectedCells()).toHaveLength(0);
|
|
686
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith(null);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("Ctrl+A selects every cell; Escape clears it", async () => {
|
|
690
|
+
const user = userEvent.setup();
|
|
691
|
+
setup({ selectable: true });
|
|
692
|
+
await user.tab();
|
|
693
|
+
await user.keyboard("{ArrowDown}");
|
|
694
|
+
await user.keyboard("{Control>}a{/Control}");
|
|
695
|
+
expect(selectedCells()).toHaveLength(6); // 3 rows x 2 columns
|
|
696
|
+
await user.keyboard("{Escape}");
|
|
697
|
+
expect(selectedCells()).toHaveLength(0);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("Shift+click extends from the previously active cell", async () => {
|
|
701
|
+
const user = userEvent.setup();
|
|
702
|
+
setup({ selectable: true });
|
|
703
|
+
await user.tab();
|
|
704
|
+
await user.keyboard("{ArrowDown}"); // active: row 0, Name
|
|
705
|
+
await user.keyboard("{Shift>}");
|
|
706
|
+
await user.click(cell(2, 1));
|
|
707
|
+
await user.keyboard("{/Shift}");
|
|
708
|
+
expect(selectedCells()).toHaveLength(6);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("a plain click clears the selection", async () => {
|
|
712
|
+
const user = userEvent.setup();
|
|
713
|
+
setup({ selectable: true });
|
|
714
|
+
await user.tab();
|
|
715
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{/Shift}");
|
|
716
|
+
expect(selectedCells().length).toBeGreaterThan(0);
|
|
717
|
+
await user.click(cell(2, 0));
|
|
718
|
+
expect(selectedCells()).toHaveLength(0);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("Ctrl+C copies the range as tab-separated values", async () => {
|
|
722
|
+
const user = userEvent.setup();
|
|
723
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
724
|
+
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
|
725
|
+
setup({ selectable: true });
|
|
726
|
+
await user.tab();
|
|
727
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{ArrowRight}{/Shift}");
|
|
728
|
+
await user.keyboard("{Control>}c{/Control}");
|
|
729
|
+
expect(writeText).toHaveBeenCalledWith("Cara\t1\nAbe\t2");
|
|
730
|
+
expect(screen.getByRole("status")).toHaveTextContent("Copied 4 cells");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("Shift+click extends from the anchor even after it has scrolled out of the rendered window", async () => {
|
|
734
|
+
const user = userEvent.setup();
|
|
735
|
+
const onSelectionChange = vi.fn();
|
|
736
|
+
const many = Array.from({ length: 300 }, (_, i) => ({ id: i + 1, name: `Person ${i + 1}` }));
|
|
737
|
+
setup({ selectable: true, data: many, onSelectionChange });
|
|
738
|
+
await user.tab();
|
|
739
|
+
await user.keyboard("{ArrowDown}"); // active + anchor: row 0
|
|
740
|
+
const grid = screen.getByRole("grid");
|
|
741
|
+
grid.scrollTop = 4000;
|
|
742
|
+
grid.dispatchEvent(new Event("scroll"));
|
|
743
|
+
await waitFor(() => expect(screen.queryByText("Person 1")).not.toBeInTheDocument());
|
|
744
|
+
|
|
745
|
+
const target = screen.getAllByRole("gridcell").find((c) => c.getAttribute("data-cell")?.endsWith(":name"))!;
|
|
746
|
+
const targetRow = Number(target.getAttribute("data-cell")!.split(":")[0]);
|
|
747
|
+
expect(targetRow).toBeGreaterThan(50);
|
|
748
|
+
|
|
749
|
+
await user.keyboard("{Shift>}");
|
|
750
|
+
await user.click(target);
|
|
751
|
+
await user.keyboard("{/Shift}");
|
|
752
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith({ rows: [0, targetRow], columns: ["name"] });
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("re-sorting clears the selection (it refers to displayed rows)", async () => {
|
|
756
|
+
const user = userEvent.setup();
|
|
757
|
+
setup({ selectable: true });
|
|
758
|
+
await user.tab();
|
|
759
|
+
await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{/Shift}");
|
|
760
|
+
expect(selectedCells().length).toBeGreaterThan(0);
|
|
761
|
+
await user.keyboard("{ArrowUp}{ArrowUp}{Enter}"); // Name header, sort
|
|
762
|
+
expect(selectedCells()).toHaveLength(0);
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
615
766
|
describe("with many rows (virtualized)", () => {
|
|
616
767
|
const many: Row[] = Array.from({ length: 200 }, (_, i) => ({ id: i + 1, name: `Person ${i + 1}` }));
|
|
617
768
|
|
|
@@ -19,6 +19,7 @@ const Demo = () => {
|
|
|
19
19
|
return (
|
|
20
20
|
<DataGrid
|
|
21
21
|
className="w-full"
|
|
22
|
+
selectable
|
|
22
23
|
height={280}
|
|
23
24
|
data={data}
|
|
24
25
|
getRowId={(row) => row.id}
|
|
@@ -45,7 +46,7 @@ const meta = {
|
|
|
45
46
|
title: "Data Display/DataGrid",
|
|
46
47
|
parameters: {
|
|
47
48
|
layout: "padded",
|
|
48
|
-
docs: { source: { code: "<DataGrid\\n height={280}\\n data={data}\\n getRowId={(row) => row.id}\\n columns={[\\n { id: \"id\", header: \"ID\", cell: (row) => row.id, value: (row) => row.id, sortable: true, pinned: \"left\" },\\n { id: \"name\", header: \"Name\", cell: (row) => row.name, value: (row) => row.name, editable: true, onCellEdit: updateName },\\n { id: \"qty\", header: \"Qty\", cell: (row) => row.qty, value: (row) => row.qty, sortable: true },\\n ]}\\n/>", language: "tsx" } },
|
|
49
|
+
docs: { source: { code: "<DataGrid\\n selectable\\n height={280}\\n data={data}\\n getRowId={(row) => row.id}\\n columns={[\\n { id: \"id\", header: \"ID\", cell: (row) => row.id, value: (row) => row.id, sortable: true, pinned: \"left\" },\\n { id: \"name\", header: \"Name\", cell: (row) => row.name, value: (row) => row.name, editable: true, onCellEdit: updateName },\\n { id: \"qty\", header: \"Qty\", cell: (row) => row.qty, value: (row) => row.qty, sortable: true },\\n ]}\\n/>", language: "tsx" } },
|
|
49
50
|
},
|
|
50
51
|
} satisfies Meta;
|
|
51
52
|
|