@kinetixui/ui 0.20.1 → 0.21.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 +3 -2
- package/dist/index.js +115 -3
- package/package.json +2 -2
- package/src/components/data-grid.tsx +134 -4
- package/src/components-keyboard.test.tsx +78 -0
package/dist/index.d.ts
CHANGED
|
@@ -1645,8 +1645,9 @@ interface DataGridProps<TData> {
|
|
|
1645
1645
|
/**
|
|
1646
1646
|
* Opt in to range selection: drag, or Shift+arrows / Shift+click, to extend a rectangle from the
|
|
1647
1647
|
* anchor cell; Ctrl/Cmd+click or Ctrl+Space starts an additional, separate range; Ctrl/Cmd+A selects
|
|
1648
|
-
* everything; Ctrl/Cmd+
|
|
1649
|
-
* a
|
|
1648
|
+
* everything; Ctrl/Cmd+click (or Ctrl+Space) on a column header selects the whole column and Shift+click extends it
|
|
1649
|
+
* (a plain header click still sorts); Shift+Space on a cell selects its whole row; Ctrl/Cmd+C copies the ranges as tab-separated text (each column's `value()`; empty where
|
|
1650
|
+
* a column has none; ranges separated by a blank line); Esc clears; dragging past the grid's edge scrolls it. Sets aria-multiselectable.
|
|
1650
1651
|
*/
|
|
1651
1652
|
selectable?: boolean;
|
|
1652
1653
|
/** fires when the selected range changes; `null` when it is cleared */
|
package/dist/index.js
CHANGED
|
@@ -5150,13 +5150,43 @@ function DataGrid({
|
|
|
5150
5150
|
const [sel, setSel] = React83.useState(null);
|
|
5151
5151
|
const [extra, setExtra] = React83.useState([]);
|
|
5152
5152
|
const dragRef = React83.useRef(null);
|
|
5153
|
+
const pointerRef = React83.useRef(null);
|
|
5154
|
+
const frameRef = React83.useRef(0);
|
|
5155
|
+
const autoScrollStepRef = React83.useRef(() => false);
|
|
5156
|
+
const dragEndedRef = React83.useRef(() => {
|
|
5157
|
+
});
|
|
5153
5158
|
React83.useEffect(() => {
|
|
5159
|
+
const tick = () => {
|
|
5160
|
+
frameRef.current = 0;
|
|
5161
|
+
if (dragRef.current && autoScrollStepRef.current()) frameRef.current = requestAnimationFrame(tick);
|
|
5162
|
+
};
|
|
5163
|
+
const move = (e) => {
|
|
5164
|
+
if (!dragRef.current) return;
|
|
5165
|
+
if (e.buttons !== 1) {
|
|
5166
|
+
dragRef.current = null;
|
|
5167
|
+
dragEndedRef.current();
|
|
5168
|
+
return;
|
|
5169
|
+
}
|
|
5170
|
+
pointerRef.current = { x: e.clientX, y: e.clientY };
|
|
5171
|
+
if (!frameRef.current) frameRef.current = requestAnimationFrame(tick);
|
|
5172
|
+
};
|
|
5154
5173
|
const end = () => {
|
|
5174
|
+
const wasDragging = dragRef.current !== null;
|
|
5155
5175
|
dragRef.current = null;
|
|
5176
|
+
pointerRef.current = null;
|
|
5177
|
+
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
|
5178
|
+
frameRef.current = 0;
|
|
5179
|
+
if (wasDragging) dragEndedRef.current();
|
|
5156
5180
|
};
|
|
5181
|
+
window.addEventListener("mousemove", move);
|
|
5157
5182
|
window.addEventListener("mouseup", end);
|
|
5158
|
-
return () =>
|
|
5183
|
+
return () => {
|
|
5184
|
+
window.removeEventListener("mousemove", move);
|
|
5185
|
+
window.removeEventListener("mouseup", end);
|
|
5186
|
+
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
|
5187
|
+
};
|
|
5159
5188
|
}, []);
|
|
5189
|
+
const lastAutoCellRef = React83.useRef(null);
|
|
5160
5190
|
const clearSel = () => {
|
|
5161
5191
|
setSel(null);
|
|
5162
5192
|
setExtra([]);
|
|
@@ -5185,6 +5215,7 @@ function DataGrid({
|
|
|
5185
5215
|
const hasSelection = rects.length > 0;
|
|
5186
5216
|
const cellCount = rects.reduce((n, r) => n + (r.r1 - r.r0 + 1) * (r.c1 - r.c0 + 1), 0);
|
|
5187
5217
|
const isSelectedCell = (row, colIdx) => rects.some((r) => row >= r.r0 && row <= r.r1 && colIdx >= r.c0 && colIdx <= r.c1);
|
|
5218
|
+
const isColumnSelected = (colIdx) => rowCount > 0 && rects.some((r) => r.r0 === 0 && r.r1 === rowCount - 1 && colIdx >= r.c0 && colIdx <= r.c1);
|
|
5188
5219
|
React83.useEffect(() => {
|
|
5189
5220
|
setSel(null);
|
|
5190
5221
|
setExtra([]);
|
|
@@ -5210,6 +5241,23 @@ function DataGrid({
|
|
|
5210
5241
|
if (previous) setExtra((x) => [...x, previous]);
|
|
5211
5242
|
setSel({ anchor: { row, columnId }, end: { row, columnId } });
|
|
5212
5243
|
}
|
|
5244
|
+
function selectColumn(columnId, mode) {
|
|
5245
|
+
if (rowCount === 0) return;
|
|
5246
|
+
const last = rowCount - 1;
|
|
5247
|
+
if (mode === "extend") {
|
|
5248
|
+
const from = sel?.anchor.columnId ?? columnId;
|
|
5249
|
+
setSel({ anchor: { row: 0, columnId: from }, end: { row: last, columnId } });
|
|
5250
|
+
return;
|
|
5251
|
+
}
|
|
5252
|
+
if (mode === "add" && sel) setExtra((x) => [...x, sel]);
|
|
5253
|
+
else if (mode === "replace") setExtra([]);
|
|
5254
|
+
setSel({ anchor: { row: 0, columnId }, end: { row: last, columnId } });
|
|
5255
|
+
}
|
|
5256
|
+
function selectRow(row) {
|
|
5257
|
+
if (colIds.length === 0) return;
|
|
5258
|
+
setExtra([]);
|
|
5259
|
+
setSel({ anchor: { row, columnId: colIds[0] }, end: { row, columnId: colIds[colIds.length - 1] } });
|
|
5260
|
+
}
|
|
5213
5261
|
function selectAll() {
|
|
5214
5262
|
if (rowCount === 0 || colIds.length === 0) return;
|
|
5215
5263
|
setExtra([]);
|
|
@@ -5258,6 +5306,56 @@ function DataGrid({
|
|
|
5258
5306
|
pendingFocusRef.current = { row: r, columnId };
|
|
5259
5307
|
setActive({ row: r, columnId });
|
|
5260
5308
|
}
|
|
5309
|
+
const AUTOSCROLL_EDGE = 12;
|
|
5310
|
+
const AUTOSCROLL_MAX = 40;
|
|
5311
|
+
autoScrollStepRef.current = () => {
|
|
5312
|
+
const el = containerRef.current;
|
|
5313
|
+
const p = pointerRef.current;
|
|
5314
|
+
const start = dragRef.current;
|
|
5315
|
+
if (!el || !p || !start || rowCount === 0 || el.clientHeight === 0) return false;
|
|
5316
|
+
const rect = el.getBoundingClientRect();
|
|
5317
|
+
const rtl = getComputedStyle(el).direction === "rtl";
|
|
5318
|
+
const widthOf = (c) => widths[c.id] ?? c.width ?? DEFAULT_WIDTH;
|
|
5319
|
+
const leftPinnedWidth = renderedColumns.filter((c) => c.pinned === "left").reduce((n, c) => n + widthOf(c), 0);
|
|
5320
|
+
const rightPinnedWidth = renderedColumns.filter((c) => c.pinned === "right").reduce((n, c) => n + widthOf(c), 0);
|
|
5321
|
+
const top = rect.top + HEADER_HEIGHT;
|
|
5322
|
+
const bottom = rect.top + el.clientHeight;
|
|
5323
|
+
const left = rect.left + leftPinnedWidth;
|
|
5324
|
+
const right = rect.left + el.clientWidth - rightPinnedWidth;
|
|
5325
|
+
const push = (over) => Math.min(AUTOSCROLL_MAX, 2 + Math.abs(over) / 2) * Math.sign(over);
|
|
5326
|
+
const dy = p.y < top + AUTOSCROLL_EDGE ? push(p.y - (top + AUTOSCROLL_EDGE)) : p.y > bottom - AUTOSCROLL_EDGE ? push(p.y - (bottom - AUTOSCROLL_EDGE)) : 0;
|
|
5327
|
+
const dx = rtl ? 0 : p.x < left + AUTOSCROLL_EDGE ? push(p.x - (left + AUTOSCROLL_EDGE)) : p.x > right - AUTOSCROLL_EDGE ? push(p.x - (right - AUTOSCROLL_EDGE)) : 0;
|
|
5328
|
+
if (dx === 0 && dy === 0) return false;
|
|
5329
|
+
el.scrollTop += dy;
|
|
5330
|
+
el.scrollLeft += dx;
|
|
5331
|
+
const cy = Math.max(top + 1, Math.min(p.y, bottom - 1));
|
|
5332
|
+
const row = Math.max(0, Math.min(rowCount - 1, Math.floor((cy - top + el.scrollTop) / rowHeight)));
|
|
5333
|
+
let columnId;
|
|
5334
|
+
if (!rtl) {
|
|
5335
|
+
const cx = Math.max(left + 1, Math.min(p.x, right - 1)) - rect.left + el.scrollLeft;
|
|
5336
|
+
let acc = 0;
|
|
5337
|
+
for (const c of renderedColumns) {
|
|
5338
|
+
acc += widthOf(c);
|
|
5339
|
+
if (cx < acc) {
|
|
5340
|
+
columnId = c.id;
|
|
5341
|
+
break;
|
|
5342
|
+
}
|
|
5343
|
+
}
|
|
5344
|
+
columnId ??= renderedColumns[renderedColumns.length - 1]?.id;
|
|
5345
|
+
}
|
|
5346
|
+
setSel((prev) => {
|
|
5347
|
+
const col = columnId ?? prev?.end.columnId ?? start.columnId;
|
|
5348
|
+
if (prev && prev.end.row === row && prev.end.columnId === col) return prev;
|
|
5349
|
+
return { anchor: start, end: { row, columnId: col } };
|
|
5350
|
+
});
|
|
5351
|
+
lastAutoCellRef.current = { row, columnId: columnId ?? lastAutoCellRef.current?.columnId ?? start.columnId };
|
|
5352
|
+
return true;
|
|
5353
|
+
};
|
|
5354
|
+
dragEndedRef.current = () => {
|
|
5355
|
+
const cell = lastAutoCellRef.current;
|
|
5356
|
+
lastAutoCellRef.current = null;
|
|
5357
|
+
if (cell) moveTo(cell.row, cell.columnId);
|
|
5358
|
+
};
|
|
5261
5359
|
React83.useEffect(() => {
|
|
5262
5360
|
const p = pendingFocusRef.current;
|
|
5263
5361
|
if (!p) return;
|
|
@@ -5304,6 +5402,14 @@ function DataGrid({
|
|
|
5304
5402
|
return;
|
|
5305
5403
|
}
|
|
5306
5404
|
const mod = e.ctrlKey || e.metaKey;
|
|
5405
|
+
if (selectable && row < 0 && mod && e.key === " ") {
|
|
5406
|
+
e.preventDefault();
|
|
5407
|
+
return selectColumn(column.id, "add");
|
|
5408
|
+
}
|
|
5409
|
+
if (selectable && row >= 0 && e.shiftKey && !mod && e.key === " ") {
|
|
5410
|
+
e.preventDefault();
|
|
5411
|
+
return selectRow(row);
|
|
5412
|
+
}
|
|
5307
5413
|
if (selectable && row >= 0) {
|
|
5308
5414
|
if (mod && e.key.toLowerCase() === "a") {
|
|
5309
5415
|
e.preventDefault();
|
|
@@ -5388,6 +5494,7 @@ function DataGrid({
|
|
|
5388
5494
|
"data-header": column.id,
|
|
5389
5495
|
"aria-colindex": colIdx + 1,
|
|
5390
5496
|
"aria-keyshortcuts": `Shift+ArrowLeft Shift+ArrowRight${column.pinned ? "" : " Alt+ArrowLeft Alt+ArrowRight"}`,
|
|
5497
|
+
"aria-selected": selectable ? isColumnSelected(colIdx) : void 0,
|
|
5391
5498
|
...column.sortable ? { "aria-sort": isSorted ? sort.direction === "asc" ? "ascending" : "descending" : "none" } : {},
|
|
5392
5499
|
draggable: !column.pinned,
|
|
5393
5500
|
onDragStart: () => {
|
|
@@ -5408,14 +5515,19 @@ function DataGrid({
|
|
|
5408
5515
|
return next;
|
|
5409
5516
|
});
|
|
5410
5517
|
},
|
|
5411
|
-
onClick: () =>
|
|
5518
|
+
onClick: (e) => {
|
|
5519
|
+
if (selectable && (e.ctrlKey || e.metaKey)) return selectColumn(column.id, "add");
|
|
5520
|
+
if (selectable && e.shiftKey) return selectColumn(column.id, "extend");
|
|
5521
|
+
if (column.sortable) toggleSort(column.id);
|
|
5522
|
+
},
|
|
5412
5523
|
tabIndex: tabStop.row === -1 && tabStop.columnId === column.id ? 0 : -1,
|
|
5413
5524
|
onFocus: () => setActive({ row: -1, columnId: column.id }),
|
|
5414
5525
|
onKeyDown: (e) => onNavKeyDown(e, -1, column),
|
|
5415
5526
|
className: cn(
|
|
5416
5527
|
"relative flex h-10 shrink-0 select-none items-center gap-1 px-2 font-medium text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
|
|
5417
5528
|
column.sortable && "cursor-pointer hover:text-foreground",
|
|
5418
|
-
column.pinned && "bg-background"
|
|
5529
|
+
column.pinned && "bg-background",
|
|
5530
|
+
selectable && isColumnSelected(colIdx) && "bg-accent text-accent-foreground"
|
|
5419
5531
|
),
|
|
5420
5532
|
style: { width, ...pinnedStyle },
|
|
5421
5533
|
children: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kinetixui/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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.21.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",
|
|
@@ -50,8 +50,9 @@ export interface DataGridProps<TData> {
|
|
|
50
50
|
/**
|
|
51
51
|
* Opt in to range selection: drag, or Shift+arrows / Shift+click, to extend a rectangle from the
|
|
52
52
|
* anchor cell; Ctrl/Cmd+click or Ctrl+Space starts an additional, separate range; Ctrl/Cmd+A selects
|
|
53
|
-
* everything; Ctrl/Cmd+
|
|
54
|
-
* a
|
|
53
|
+
* everything; Ctrl/Cmd+click (or Ctrl+Space) on a column header selects the whole column and Shift+click extends it
|
|
54
|
+
* (a plain header click still sorts); Shift+Space on a cell selects its whole row; Ctrl/Cmd+C copies the ranges as tab-separated text (each column's `value()`; empty where
|
|
55
|
+
* a column has none; ranges separated by a blank line); Esc clears; dragging past the grid's edge scrolls it. Sets aria-multiselectable.
|
|
55
56
|
*/
|
|
56
57
|
selectable?: boolean;
|
|
57
58
|
/** fires when the selected range changes; `null` when it is cleared */
|
|
@@ -235,13 +236,45 @@ function DataGrid<TData>({
|
|
|
235
236
|
/** ranges committed before the current one (Ctrl/Cmd+click, Ctrl+Space) */
|
|
236
237
|
const [extra, setExtra] = React.useState<Sel[]>([]);
|
|
237
238
|
const dragRef = React.useRef<Anchor | null>(null);
|
|
239
|
+
// Auto-scroll while drag-selecting: the pointer position is tracked on the window (it leaves the
|
|
240
|
+
// grid, so cell mouseenter events stop), and a frame loop scrolls the grid and extends the range
|
|
241
|
+
// to the cell under the pointer while the pointer is at or past an edge.
|
|
242
|
+
const pointerRef = React.useRef<{ x: number; y: number } | null>(null);
|
|
243
|
+
const frameRef = React.useRef(0);
|
|
244
|
+
const autoScrollStepRef = React.useRef<() => boolean>(() => false);
|
|
245
|
+
const dragEndedRef = React.useRef<() => void>(() => {});
|
|
238
246
|
React.useEffect(() => {
|
|
247
|
+
const tick = () => {
|
|
248
|
+
frameRef.current = 0;
|
|
249
|
+
if (dragRef.current && autoScrollStepRef.current()) frameRef.current = requestAnimationFrame(tick);
|
|
250
|
+
};
|
|
251
|
+
const move = (e: MouseEvent) => {
|
|
252
|
+
if (!dragRef.current) return;
|
|
253
|
+
if (e.buttons !== 1) {
|
|
254
|
+
dragRef.current = null; // the button was released outside the window
|
|
255
|
+
dragEndedRef.current();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
pointerRef.current = { x: e.clientX, y: e.clientY };
|
|
259
|
+
if (!frameRef.current) frameRef.current = requestAnimationFrame(tick);
|
|
260
|
+
};
|
|
239
261
|
const end = () => {
|
|
262
|
+
const wasDragging = dragRef.current !== null;
|
|
240
263
|
dragRef.current = null;
|
|
264
|
+
pointerRef.current = null;
|
|
265
|
+
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
|
266
|
+
frameRef.current = 0;
|
|
267
|
+
if (wasDragging) dragEndedRef.current();
|
|
241
268
|
};
|
|
269
|
+
window.addEventListener("mousemove", move);
|
|
242
270
|
window.addEventListener("mouseup", end);
|
|
243
|
-
return () =>
|
|
271
|
+
return () => {
|
|
272
|
+
window.removeEventListener("mousemove", move);
|
|
273
|
+
window.removeEventListener("mouseup", end);
|
|
274
|
+
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
|
275
|
+
};
|
|
244
276
|
}, []);
|
|
277
|
+
const lastAutoCellRef = React.useRef<Pos | null>(null);
|
|
245
278
|
const clearSel = () => {
|
|
246
279
|
setSel(null);
|
|
247
280
|
setExtra([]);
|
|
@@ -275,6 +308,9 @@ function DataGrid<TData>({
|
|
|
275
308
|
const cellCount = rects.reduce((n, r) => n + (r.r1 - r.r0 + 1) * (r.c1 - r.c0 + 1), 0);
|
|
276
309
|
const isSelectedCell = (row: number, colIdx: number) => rects.some((r) => row >= r.r0 && row <= r.r1 && colIdx >= r.c0 && colIdx <= r.c1);
|
|
277
310
|
|
|
311
|
+
/** a column header reads as selected when a range covers every row of that column */
|
|
312
|
+
const isColumnSelected = (colIdx: number) => rowCount > 0 && rects.some((r) => r.r0 === 0 && r.r1 === rowCount - 1 && colIdx >= r.c0 && colIdx <= r.c1);
|
|
313
|
+
|
|
278
314
|
// Selection refers to displayed rows, so a re-sort or a different row count invalidates it.
|
|
279
315
|
React.useEffect(() => {
|
|
280
316
|
setSel(null);
|
|
@@ -307,6 +343,27 @@ function DataGrid<TData>({
|
|
|
307
343
|
setSel({ anchor: { row, columnId }, end: { row, columnId } });
|
|
308
344
|
}
|
|
309
345
|
|
|
346
|
+
/** Select a whole column (Ctrl/Cmd+click, Ctrl+Space on its header), keeping other ranges; or, with `extend`, span from the anchor column. */
|
|
347
|
+
function selectColumn(columnId: string, mode: "replace" | "add" | "extend") {
|
|
348
|
+
if (rowCount === 0) return;
|
|
349
|
+
const last = rowCount - 1;
|
|
350
|
+
if (mode === "extend") {
|
|
351
|
+
const from = sel?.anchor.columnId ?? columnId;
|
|
352
|
+
setSel({ anchor: { row: 0, columnId: from }, end: { row: last, columnId } });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (mode === "add" && sel) setExtra((x) => [...x, sel]);
|
|
356
|
+
else if (mode === "replace") setExtra([]);
|
|
357
|
+
setSel({ anchor: { row: 0, columnId }, end: { row: last, columnId } });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Select a whole row (Shift+Space on one of its cells). */
|
|
361
|
+
function selectRow(row: number) {
|
|
362
|
+
if (colIds.length === 0) return;
|
|
363
|
+
setExtra([]);
|
|
364
|
+
setSel({ anchor: { row, columnId: colIds[0]! }, end: { row, columnId: colIds[colIds.length - 1]! } });
|
|
365
|
+
}
|
|
366
|
+
|
|
310
367
|
function selectAll() {
|
|
311
368
|
if (rowCount === 0 || colIds.length === 0) return;
|
|
312
369
|
setExtra([]);
|
|
@@ -364,6 +421,63 @@ function DataGrid<TData>({
|
|
|
364
421
|
setActive({ row: r, columnId });
|
|
365
422
|
}
|
|
366
423
|
|
|
424
|
+
const AUTOSCROLL_EDGE = 12; // px inside the edge where scrolling starts
|
|
425
|
+
const AUTOSCROLL_MAX = 40; // px per frame
|
|
426
|
+
/** One frame of drag auto-scroll: scroll toward the pointer and extend the range to the cell it points at. */
|
|
427
|
+
autoScrollStepRef.current = () => {
|
|
428
|
+
const el = containerRef.current;
|
|
429
|
+
const p = pointerRef.current;
|
|
430
|
+
const start = dragRef.current;
|
|
431
|
+
if (!el || !p || !start || rowCount === 0 || el.clientHeight === 0) return false; // no layout (hidden grid): nothing to scroll
|
|
432
|
+
const rect = el.getBoundingClientRect();
|
|
433
|
+
const rtl = getComputedStyle(el).direction === "rtl";
|
|
434
|
+
const widthOf = (c: DataGridColumn<TData>) => widths[c.id] ?? c.width ?? DEFAULT_WIDTH;
|
|
435
|
+
const leftPinnedWidth = renderedColumns.filter((c) => c.pinned === "left").reduce((n, c) => n + widthOf(c), 0);
|
|
436
|
+
const rightPinnedWidth = renderedColumns.filter((c) => c.pinned === "right").reduce((n, c) => n + widthOf(c), 0);
|
|
437
|
+
// the area the range can grow into: under the sticky header, between the pinned columns
|
|
438
|
+
const top = rect.top + HEADER_HEIGHT;
|
|
439
|
+
const bottom = rect.top + el.clientHeight;
|
|
440
|
+
const left = rect.left + leftPinnedWidth;
|
|
441
|
+
const right = rect.left + el.clientWidth - rightPinnedWidth;
|
|
442
|
+
const push = (over: number) => Math.min(AUTOSCROLL_MAX, 2 + Math.abs(over) / 2) * Math.sign(over);
|
|
443
|
+
const dy = p.y < top + AUTOSCROLL_EDGE ? push(p.y - (top + AUTOSCROLL_EDGE)) : p.y > bottom - AUTOSCROLL_EDGE ? push(p.y - (bottom - AUTOSCROLL_EDGE)) : 0;
|
|
444
|
+
const dx = rtl ? 0 : p.x < left + AUTOSCROLL_EDGE ? push(p.x - (left + AUTOSCROLL_EDGE)) : p.x > right - AUTOSCROLL_EDGE ? push(p.x - (right - AUTOSCROLL_EDGE)) : 0;
|
|
445
|
+
if (dx === 0 && dy === 0) return false;
|
|
446
|
+
el.scrollTop += dy;
|
|
447
|
+
el.scrollLeft += dx;
|
|
448
|
+
|
|
449
|
+
// the cell under the pointer, clamped into the growable area — from geometry, not the DOM, since the
|
|
450
|
+
// virtualized row under the pointer may not have rendered yet
|
|
451
|
+
const cy = Math.max(top + 1, Math.min(p.y, bottom - 1));
|
|
452
|
+
const row = Math.max(0, Math.min(rowCount - 1, Math.floor((cy - top + el.scrollTop) / rowHeight)));
|
|
453
|
+
let columnId: string | undefined;
|
|
454
|
+
if (!rtl) {
|
|
455
|
+
const cx = Math.max(left + 1, Math.min(p.x, right - 1)) - rect.left + el.scrollLeft;
|
|
456
|
+
let acc = 0;
|
|
457
|
+
for (const c of renderedColumns) {
|
|
458
|
+
acc += widthOf(c);
|
|
459
|
+
if (cx < acc) {
|
|
460
|
+
columnId = c.id;
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
columnId ??= renderedColumns[renderedColumns.length - 1]?.id;
|
|
465
|
+
}
|
|
466
|
+
setSel((prev) => {
|
|
467
|
+
const col = columnId ?? prev?.end.columnId ?? start.columnId;
|
|
468
|
+
if (prev && prev.end.row === row && prev.end.columnId === col) return prev;
|
|
469
|
+
return { anchor: start, end: { row, columnId: col } };
|
|
470
|
+
});
|
|
471
|
+
lastAutoCellRef.current = { row, columnId: columnId ?? lastAutoCellRef.current?.columnId ?? start.columnId };
|
|
472
|
+
return true;
|
|
473
|
+
};
|
|
474
|
+
// After an auto-scrolled drag, the last cell reached becomes the active one so Shift+arrows continue from it.
|
|
475
|
+
dragEndedRef.current = () => {
|
|
476
|
+
const cell = lastAutoCellRef.current;
|
|
477
|
+
lastAutoCellRef.current = null;
|
|
478
|
+
if (cell) moveTo(cell.row, cell.columnId);
|
|
479
|
+
};
|
|
480
|
+
|
|
367
481
|
// Runs after every render until the pending target exists in the DOM (it may need a scroll first).
|
|
368
482
|
React.useEffect(() => {
|
|
369
483
|
const p = pendingFocusRef.current;
|
|
@@ -417,6 +531,15 @@ function DataGrid<TData>({
|
|
|
417
531
|
}
|
|
418
532
|
|
|
419
533
|
const mod = e.ctrlKey || e.metaKey;
|
|
534
|
+
if (selectable && row < 0 && mod && e.key === " ") {
|
|
535
|
+
// header: select this column, keeping what is selected (the keyboard twin of Ctrl/Cmd+click)
|
|
536
|
+
e.preventDefault();
|
|
537
|
+
return selectColumn(column.id, "add");
|
|
538
|
+
}
|
|
539
|
+
if (selectable && row >= 0 && e.shiftKey && !mod && e.key === " ") {
|
|
540
|
+
e.preventDefault();
|
|
541
|
+
return selectRow(row);
|
|
542
|
+
}
|
|
420
543
|
if (selectable && row >= 0) {
|
|
421
544
|
if (mod && e.key.toLowerCase() === "a") {
|
|
422
545
|
e.preventDefault();
|
|
@@ -509,6 +632,7 @@ function DataGrid<TData>({
|
|
|
509
632
|
data-header={column.id}
|
|
510
633
|
aria-colindex={colIdx + 1}
|
|
511
634
|
aria-keyshortcuts={`Shift+ArrowLeft Shift+ArrowRight${column.pinned ? "" : " Alt+ArrowLeft Alt+ArrowRight"}`}
|
|
635
|
+
aria-selected={selectable ? isColumnSelected(colIdx) : undefined}
|
|
512
636
|
{...(column.sortable
|
|
513
637
|
? { "aria-sort": isSorted ? (sort!.direction === "asc" ? "ascending" : "descending") : "none" }
|
|
514
638
|
: {})}
|
|
@@ -531,7 +655,12 @@ function DataGrid<TData>({
|
|
|
531
655
|
return next;
|
|
532
656
|
});
|
|
533
657
|
}}
|
|
534
|
-
onClick={() =>
|
|
658
|
+
onClick={(e) => {
|
|
659
|
+
// with selection on, Ctrl/Cmd+click and Shift+click select the column instead of sorting
|
|
660
|
+
if (selectable && (e.ctrlKey || e.metaKey)) return selectColumn(column.id, "add");
|
|
661
|
+
if (selectable && e.shiftKey) return selectColumn(column.id, "extend");
|
|
662
|
+
if (column.sortable) toggleSort(column.id);
|
|
663
|
+
}}
|
|
535
664
|
// roving tabindex: the grid is one tab stop; arrows move between cells (see onNavKeyDown)
|
|
536
665
|
tabIndex={tabStop.row === -1 && tabStop.columnId === column.id ? 0 : -1}
|
|
537
666
|
onFocus={() => setActive({ row: -1, columnId: column.id })}
|
|
@@ -540,6 +669,7 @@ function DataGrid<TData>({
|
|
|
540
669
|
"relative flex h-10 shrink-0 select-none items-center gap-1 px-2 font-medium text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
|
|
541
670
|
column.sortable && "cursor-pointer hover:text-foreground",
|
|
542
671
|
column.pinned && "bg-background",
|
|
672
|
+
selectable && isColumnSelected(colIdx) && "bg-accent text-accent-foreground",
|
|
543
673
|
)}
|
|
544
674
|
style={{ width, ...pinnedStyle }}
|
|
545
675
|
>
|
|
@@ -708,6 +708,84 @@ describe("DataGrid", () => {
|
|
|
708
708
|
expect(selectedCells()).toHaveLength(6);
|
|
709
709
|
});
|
|
710
710
|
|
|
711
|
+
describe("whole columns and rows", () => {
|
|
712
|
+
it("Ctrl+click on a header selects that whole column; a plain click still sorts", async () => {
|
|
713
|
+
const user = userEvent.setup();
|
|
714
|
+
const onSelectionChange = vi.fn();
|
|
715
|
+
setup({ selectable: true, onSelectionChange });
|
|
716
|
+
await user.keyboard("{Control>}");
|
|
717
|
+
await user.click(header("Name"));
|
|
718
|
+
await user.keyboard("{/Control}");
|
|
719
|
+
expect(selectedCells()).toHaveLength(3);
|
|
720
|
+
expect(header("Name")).toHaveAttribute("aria-selected", "true");
|
|
721
|
+
expect(header("ID")).toHaveAttribute("aria-selected", "false");
|
|
722
|
+
expect(header("Name")).toHaveAttribute("aria-sort", "none"); // selecting did not sort
|
|
723
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith({ rows: [0, 2], columns: ["name"], ranges: [{ rows: [0, 2], columns: ["name"] }] });
|
|
724
|
+
|
|
725
|
+
await user.click(header("Name")); // plain click: sort
|
|
726
|
+
expect(header("Name")).toHaveAttribute("aria-sort", "ascending");
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("Ctrl+click on a second header keeps the first column; Shift+click extends across columns", async () => {
|
|
730
|
+
const user = userEvent.setup();
|
|
731
|
+
const onSelectionChange = vi.fn();
|
|
732
|
+
setup({ selectable: true, onSelectionChange });
|
|
733
|
+
await user.keyboard("{Control>}");
|
|
734
|
+
await user.click(header("Name"));
|
|
735
|
+
await user.click(header("ID"));
|
|
736
|
+
await user.keyboard("{/Control}");
|
|
737
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith({
|
|
738
|
+
rows: [0, 2],
|
|
739
|
+
columns: ["id"],
|
|
740
|
+
ranges: [
|
|
741
|
+
{ rows: [0, 2], columns: ["name"] },
|
|
742
|
+
{ rows: [0, 2], columns: ["id"] },
|
|
743
|
+
],
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
await user.click(cell(0, 0)); // clears
|
|
747
|
+
await user.keyboard("{Control>}");
|
|
748
|
+
await user.click(header("Name"));
|
|
749
|
+
await user.keyboard("{/Control}{Shift>}");
|
|
750
|
+
await user.click(header("ID"));
|
|
751
|
+
await user.keyboard("{/Shift}");
|
|
752
|
+
expect(selectedCells()).toHaveLength(6);
|
|
753
|
+
expect(header("Name")).toHaveAttribute("aria-selected", "true");
|
|
754
|
+
expect(header("ID")).toHaveAttribute("aria-selected", "true");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("Ctrl+Space on a focused header selects its column", async () => {
|
|
758
|
+
const user = userEvent.setup();
|
|
759
|
+
setup({ selectable: true });
|
|
760
|
+
await user.tab();
|
|
761
|
+
expect(header("Name")).toHaveFocus();
|
|
762
|
+
await user.keyboard("{Control>} {/Control}");
|
|
763
|
+
expect(selectedCells()).toHaveLength(3);
|
|
764
|
+
expect(header("Name")).toHaveAttribute("aria-selected", "true");
|
|
765
|
+
expect(screen.getByRole("status")).toHaveTextContent("3 cells selected");
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("Shift+Space on a cell selects its whole row", async () => {
|
|
769
|
+
const user = userEvent.setup();
|
|
770
|
+
const onSelectionChange = vi.fn();
|
|
771
|
+
setup({ selectable: true, onSelectionChange });
|
|
772
|
+
await user.tab();
|
|
773
|
+
await user.keyboard("{ArrowDown}{ArrowDown}"); // row 1
|
|
774
|
+
await user.keyboard("{Shift>} {/Shift}");
|
|
775
|
+
expect(selectedCells()).toHaveLength(2);
|
|
776
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith({ rows: [1, 1], columns: ["name", "id"], ranges: [{ rows: [1, 1], columns: ["name", "id"] }] });
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("headers are not selectable without `selectable`: Ctrl+click just sorts nothing, no aria-selected", async () => {
|
|
780
|
+
const user = userEvent.setup();
|
|
781
|
+
setup();
|
|
782
|
+
await user.keyboard("{Control>}");
|
|
783
|
+
await user.click(header("Name"));
|
|
784
|
+
await user.keyboard("{/Control}");
|
|
785
|
+
expect(header("Name")).not.toHaveAttribute("aria-selected");
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
711
789
|
it("a plain click clears the selection", async () => {
|
|
712
790
|
const user = userEvent.setup();
|
|
713
791
|
setup({ selectable: true });
|