@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 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
- moveTo(r, colIds[Math.max(0, Math.min(i, colIds.length - 1))]);
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.18.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.18.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
- moveTo(r, colIds[Math.max(0, Math.min(i, colIds.length - 1))]!);
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(opts: { onCellEdit?: ReturnType<typeof vi.fn>; data?: Row[]; wrapper?: (n: React.ReactNode) => React.ReactNode } = {}) {
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 = <DataGrid columns={columns} data={opts.data ?? rows} height={200} getRowId={(r) => r.id} />;
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