@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 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+C copies the ranges as tab-separated text (each column's `value()`; empty where
1649
- * a column has none; ranges separated by a blank line); Esc clears. Sets aria-multiselectable.
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 () => window.removeEventListener("mouseup", end);
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: () => column.sortable && toggleSort(column.id),
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.20.1",
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.20.1",
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+C copies the ranges as tab-separated text (each column's `value()`; empty where
54
- * a column has none; ranges separated by a blank line); Esc clears. Sets aria-multiselectable.
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 () => window.removeEventListener("mouseup", end);
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={() => column.sortable && toggleSort(column.id)}
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 });