@kinetixui/ui 0.18.0 → 0.20.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,18 @@ 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 DataGridRange {
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
+ }
1631
+ /** The selection: the range being edited (`rows` / `columns`, the last one) plus every range in `ranges`. */
1632
+ interface DataGridSelection extends DataGridRange {
1633
+ /** every selected range, in the order they were added — more than one after Ctrl/Cmd+click or Ctrl+Space */
1634
+ ranges: DataGridRange[];
1635
+ }
1624
1636
  interface DataGridProps<TData> {
1625
1637
  columns: DataGridColumn<TData>[];
1626
1638
  data: TData[];
@@ -1630,6 +1642,15 @@ interface DataGridProps<TData> {
1630
1642
  overscan?: number;
1631
1643
  getRowId?: (row: TData, index: number) => React$1.Key;
1632
1644
  className?: string;
1645
+ /**
1646
+ * Opt in to range selection: drag, or Shift+arrows / Shift+click, to extend a rectangle from the
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.
1650
+ */
1651
+ selectable?: boolean;
1652
+ /** fires when the selected range changes; `null` when it is cleared */
1653
+ onSelectionChange?: (selection: DataGridSelection | null) => void;
1633
1654
  }
1634
1655
  /**
1635
1656
  * DataGrid — the "product on its own" the `COMPONENT-ADDITIONS.md` Tier 3
@@ -1647,7 +1668,7 @@ interface DataGridProps<TData> {
1647
1668
  * flexibility, at the cost of the native table-navigation semantics
1648
1669
  * `DataTable` still gets for free.
1649
1670
  */
1650
- declare function DataGrid<TData>({ columns, data, height, rowHeight, overscan, getRowId, className, }: DataGridProps<TData>): React$1.JSX.Element;
1671
+ declare function DataGrid<TData>({ columns, data, height, rowHeight, overscan, getRowId, className, selectable, onSelectionChange, }: DataGridProps<TData>): React$1.JSX.Element;
1651
1672
 
1652
1673
  interface JsonViewerProps extends Omit<React$1.HTMLAttributes<HTMLDivElement>, "children"> {
1653
1674
  data: unknown;
@@ -1797,4 +1818,4 @@ declare const MarkdownEditor: React$1.ForwardRefExoticComponent<MarkdownEditorPr
1797
1818
  /** Merges Tailwind class names, resolving conflicts (clsx + tailwind-merge). */
1798
1819
  declare function cn(...inputs: ClassValue[]): string;
1799
1820
 
1800
- export { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, AppBar, AppBarActions, AppBarBrand, AppBarLink, type AppBarLinkProps, AppBarNav, AspectRatio, AudioPlayer, type AudioPlayerProps, Avatar, AvatarFallback, AvatarGroup, type AvatarGroupProps, AvatarImage, Badge, type BadgeProps, Banner, type BannerProps, Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, ButtonGroup, type ButtonGroupProps, ButtonGroupSeparator, type ButtonGroupSeparatorProps, ButtonGroupText, type ButtonProps, Calendar, type CalendarProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Carousel, type CarouselApi, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartStyle, ChartTooltip, ChartTooltipContent, Checkbox, CircularProgress, type CircularProgressProps, CodeBlock, type CodeBlockFile, type CodeBlockProps, Collapsible, CollapsibleContent, CollapsibleTrigger, ColorPicker, type ColorPickerProps, Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, ComparisonSlider, type ComparisonSliderProps, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, DataGrid, type DataGridColumn, type DataGridProps, DataTable, DatePicker, type DatePickerProps, DescriptionList, DescriptionListItem, type DescriptionListItemProps, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DiffViewer, type DiffViewerProps, Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerPortal, DrawerTitle, DrawerTrigger, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, type EmptyMediaProps, EmptyTitle, Fab, type FabProps, Field, FieldControl, FieldDescription, FieldLabel, FieldMessage, FileUpload, FileUploadItem, type FileUploadItemProps, type FileUploadProps, Footer, FooterBottom, FooterColumn, type FooterColumnProps, FooterLink, Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, HoverCard, HoverCardContent, HoverCardTrigger, Image, type ImageProps, Inform, type InformProps, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, type InputProps, JsonViewer, type JsonViewerProps, KanbanBoard, type KanbanBoardProps, type KanbanCard, type KanbanColumn, Kbd, KbdGroup, type KbdGroupProps, type KbdProps, KinetixDirectionProvider, type KinetixDirectionProviderProps, Label, List, ListItem, type ListItemProps, MarkdownEditor, type MarkdownEditorProps, Marquee, type MarqueeProps, Menubar, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarLabel, MenubarMenu, MenubarPortal, MenubarRadioGroup, MenubarRadioItem, MenubarSeparator, MenubarShortcut, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, MessageBubble, type MessageBubbleProps, Metric, type MetricProps, Modal, type ModalProps, type ModalType, MultiSelect, type MultiSelectOption, type MultiSelectProps, NativeSelect, NativeSelectOptGroup, type NativeSelectOptGroupProps, NativeSelectOption, type NativeSelectOptionProps, type NativeSelectProps, NavigationBar, type NavigationBarProps, NavigationMenu, NavigationMenuContent, NavigationMenuIndicator, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, NavigationMenuViewport, NotificationCenter, NotificationCenterContent, type NotificationCenterContentProps, NotificationCenterTrigger, type NotificationCenterTriggerProps, NotificationItem, type NotificationItemProps, NumberInput, type NumberInputProps, PageHeader, type PageHeaderProps, Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, PasswordInput, Popover, PopoverAnchor, PopoverContent, PopoverTrigger, Progress, Quote, type QuoteProps, RadioGroup, RadioGroupItem, Rating, type RatingProps, ResizableHandle, ResizablePanel, ResizablePanelGroup, ScrollArea, ScrollBar, SegmentedControl, SegmentedControlItem, type SegmentedControlProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarProvider, SidebarSeparator, SidebarTrigger, Skeleton, Slider, Spinner, type SpinnerProps, Stepper, type StepperProps, type StepperStep, Switch, TabBar, TabBarItem, type TabBarItemProps, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableOfContents, type TableOfContentsProps, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, type TagProps, Textarea, type TextareaProps, Timeline, type TimelineItem, type TimelineProps, Toaster, type TocItem, Toggle, ToggleGroup, ToggleGroupItem, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Tour, type TourProps, type TourStep, TreeItem, type TreeItemProps, TreeView, type TreeViewProps, TypingIndicator, type UploadFile, VirtualList, type VirtualListProps, badgeVariants, bannerVariants, buttonVariants, cn, emptyMediaVariants, fabVariants, formatBytes, informVariants, inputVariants, messageVariants, nativeSelectVariants, navigationMenuTriggerStyle, spinnerVariants, tagVariants, textareaVariants, toggleVariants, useAppBar, useFormField, useSidebar };
1821
+ export { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, AppBar, AppBarActions, AppBarBrand, AppBarLink, type AppBarLinkProps, AppBarNav, AspectRatio, AudioPlayer, type AudioPlayerProps, Avatar, AvatarFallback, AvatarGroup, type AvatarGroupProps, AvatarImage, Badge, type BadgeProps, Banner, type BannerProps, Breadcrumb, BreadcrumbEllipsis, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, ButtonGroup, type ButtonGroupProps, ButtonGroupSeparator, type ButtonGroupSeparatorProps, ButtonGroupText, type ButtonProps, Calendar, type CalendarProps, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Carousel, type CarouselApi, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartStyle, ChartTooltip, ChartTooltipContent, Checkbox, CircularProgress, type CircularProgressProps, CodeBlock, type CodeBlockFile, type CodeBlockProps, Collapsible, CollapsibleContent, CollapsibleTrigger, ColorPicker, type ColorPickerProps, Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, ComparisonSlider, type ComparisonSliderProps, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, DataGrid, type DataGridColumn, type DataGridProps, type DataGridRange, type DataGridSelection, DataTable, DatePicker, type DatePickerProps, DescriptionList, DescriptionListItem, type DescriptionListItemProps, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DiffViewer, type DiffViewerProps, Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerOverlay, DrawerPortal, DrawerTitle, DrawerTrigger, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, type EmptyMediaProps, EmptyTitle, Fab, type FabProps, Field, FieldControl, FieldDescription, FieldLabel, FieldMessage, FileUpload, FileUploadItem, type FileUploadItemProps, type FileUploadProps, Footer, FooterBottom, FooterColumn, type FooterColumnProps, FooterLink, Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, HoverCard, HoverCardContent, HoverCardTrigger, Image, type ImageProps, Inform, type InformProps, Input, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, type InputProps, JsonViewer, type JsonViewerProps, KanbanBoard, type KanbanBoardProps, type KanbanCard, type KanbanColumn, Kbd, KbdGroup, type KbdGroupProps, type KbdProps, KinetixDirectionProvider, type KinetixDirectionProviderProps, Label, List, ListItem, type ListItemProps, MarkdownEditor, type MarkdownEditorProps, Marquee, type MarqueeProps, Menubar, MenubarCheckboxItem, MenubarContent, MenubarGroup, MenubarItem, MenubarLabel, MenubarMenu, MenubarPortal, MenubarRadioGroup, MenubarRadioItem, MenubarSeparator, MenubarShortcut, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger, MessageBubble, type MessageBubbleProps, Metric, type MetricProps, Modal, type ModalProps, type ModalType, MultiSelect, type MultiSelectOption, type MultiSelectProps, NativeSelect, NativeSelectOptGroup, type NativeSelectOptGroupProps, NativeSelectOption, type NativeSelectOptionProps, type NativeSelectProps, NavigationBar, type NavigationBarProps, NavigationMenu, NavigationMenuContent, NavigationMenuIndicator, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, NavigationMenuViewport, NotificationCenter, NotificationCenterContent, type NotificationCenterContentProps, NotificationCenterTrigger, type NotificationCenterTriggerProps, NotificationItem, type NotificationItemProps, NumberInput, type NumberInputProps, PageHeader, type PageHeaderProps, Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, PasswordInput, Popover, PopoverAnchor, PopoverContent, PopoverTrigger, Progress, Quote, type QuoteProps, RadioGroup, RadioGroupItem, Rating, type RatingProps, ResizableHandle, ResizablePanel, ResizablePanelGroup, ScrollArea, ScrollBar, SegmentedControl, SegmentedControlItem, type SegmentedControlProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarProvider, SidebarSeparator, SidebarTrigger, Skeleton, Slider, Spinner, type SpinnerProps, Stepper, type StepperProps, type StepperStep, Switch, TabBar, TabBarItem, type TabBarItemProps, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableOfContents, type TableOfContentsProps, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, type TagProps, Textarea, type TextareaProps, Timeline, type TimelineItem, type TimelineProps, Toaster, type TocItem, Toggle, ToggleGroup, ToggleGroupItem, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Tour, type TourProps, type TourStep, TreeItem, type TreeItemProps, TreeView, type TreeViewProps, TypingIndicator, type UploadFile, VirtualList, type VirtualListProps, badgeVariants, bannerVariants, buttonVariants, cn, emptyMediaVariants, fabVariants, formatBytes, informVariants, inputVariants, messageVariants, nativeSelectVariants, navigationMenuTriggerStyle, spinnerVariants, tagVariants, textareaVariants, toggleVariants, useAppBar, useFormField, useSidebar };
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,89 @@ 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 [extra, setExtra] = React83.useState([]);
5152
+ const dragRef = React83.useRef(null);
5153
+ React83.useEffect(() => {
5154
+ const end = () => {
5155
+ dragRef.current = null;
5156
+ };
5157
+ window.addEventListener("mouseup", end);
5158
+ return () => window.removeEventListener("mouseup", end);
5159
+ }, []);
5160
+ const clearSel = () => {
5161
+ setSel(null);
5162
+ setExtra([]);
5163
+ };
5164
+ const onSelectionChangeRef = React83.useRef(onSelectionChange);
5165
+ onSelectionChangeRef.current = onSelectionChange;
5166
+ const lastEmittedRef = React83.useRef("null");
5148
5167
  const rowCount = sortedData.length;
5149
5168
  const colIds = renderedColumns.map((c) => c.id);
5150
5169
  const activeCol = colIds.includes(active.columnId) ? active.columnId : colIds[0] ?? "";
5151
5170
  const activeRow = Math.min(active.row, rowCount - 1);
5152
5171
  const activeRendered = activeRow < 0 || activeRow >= startIndex && activeRow < endIndex;
5153
5172
  const tabStop = { row: activeRendered ? activeRow : -1, columnId: activeCol };
5173
+ const colKey = colIds.join("|");
5174
+ const rects = React83.useMemo(() => {
5175
+ if (!selectable) return [];
5176
+ const out = [];
5177
+ for (const s of sel ? [...extra, sel] : extra) {
5178
+ const a = colIds.indexOf(s.anchor.columnId);
5179
+ const b = colIds.indexOf(s.end.columnId);
5180
+ if (a < 0 || b < 0) continue;
5181
+ out.push({ r0: Math.min(s.anchor.row, s.end.row), r1: Math.max(s.anchor.row, s.end.row), c0: Math.min(a, b), c1: Math.max(a, b) });
5182
+ }
5183
+ return out;
5184
+ }, [selectable, sel, extra, colKey]);
5185
+ const hasSelection = rects.length > 0;
5186
+ const cellCount = rects.reduce((n, r) => n + (r.r1 - r.r0 + 1) * (r.c1 - r.c0 + 1), 0);
5187
+ const isSelectedCell = (row, colIdx) => rects.some((r) => row >= r.r0 && row <= r.r1 && colIdx >= r.c0 && colIdx <= r.c1);
5188
+ React83.useEffect(() => {
5189
+ setSel(null);
5190
+ setExtra([]);
5191
+ }, [sort, rowCount]);
5192
+ React83.useEffect(() => {
5193
+ if (!selectable) return;
5194
+ const ranges = rects.map((r) => ({ rows: [r.r0, r.r1], columns: colIds.slice(r.c0, r.c1 + 1) }));
5195
+ const value = ranges.length ? { ...ranges[ranges.length - 1], ranges } : null;
5196
+ const key = JSON.stringify(value);
5197
+ if (key === lastEmittedRef.current) return;
5198
+ lastEmittedRef.current = key;
5199
+ onSelectionChangeRef.current?.(value);
5200
+ setAnnouncement(value ? `${cellCount} cells selected` : "Selection cleared");
5201
+ }, [selectable, rects]);
5202
+ function extendSelection(fromRow, fromCol, toRow, toCol) {
5203
+ const anchor = sel?.anchor ?? { row: fromRow, columnId: fromCol };
5204
+ const end = { row: Math.max(0, Math.min(toRow, rowCount - 1)), columnId: toCol };
5205
+ setSel({ anchor, end });
5206
+ moveTo(end.row, end.columnId);
5207
+ }
5208
+ function addRange(row, columnId, prev) {
5209
+ const previous = sel ?? (prev ? { anchor: prev, end: prev } : null);
5210
+ if (previous) setExtra((x) => [...x, previous]);
5211
+ setSel({ anchor: { row, columnId }, end: { row, columnId } });
5212
+ }
5213
+ function selectAll() {
5214
+ if (rowCount === 0 || colIds.length === 0) return;
5215
+ setExtra([]);
5216
+ setSel({ anchor: { row: 0, columnId: colIds[0] }, end: { row: rowCount - 1, columnId: colIds[colIds.length - 1] } });
5217
+ }
5218
+ function copySelection() {
5219
+ if (!hasSelection) return;
5220
+ const text = rects.map((r) => {
5221
+ const ids = colIds.slice(r.c0, r.c1 + 1);
5222
+ return sortedData.slice(r.r0, r.r1 + 1).map((row) => ids.map((id) => {
5223
+ const c = columnById.get(id);
5224
+ return c?.value ? String(c.value(row)) : "";
5225
+ }).join(" ")).join("\n");
5226
+ }).join("\n\n");
5227
+ try {
5228
+ void navigator.clipboard?.writeText(text);
5229
+ setAnnouncement(`Copied ${cellCount} cells`);
5230
+ } catch {
5231
+ }
5232
+ }
5154
5233
  const pageRows = Math.max(1, Math.floor((viewportHeight - HEADER_HEIGHT) / rowHeight) - 1);
5155
5234
  const labelOf = (c) => typeof c.header === "string" ? c.header : c.id;
5156
5235
  function ensureRowVisible(row) {
@@ -5224,9 +5303,32 @@ function DataGrid({
5224
5303
  else resizeColumn(column, horizontal * 10);
5225
5304
  return;
5226
5305
  }
5306
+ const mod = e.ctrlKey || e.metaKey;
5307
+ if (selectable && row >= 0) {
5308
+ if (mod && e.key.toLowerCase() === "a") {
5309
+ e.preventDefault();
5310
+ return selectAll();
5311
+ }
5312
+ if (mod && e.key.toLowerCase() === "c" && hasSelection) {
5313
+ e.preventDefault();
5314
+ return copySelection();
5315
+ }
5316
+ if (mod && e.key === " ") {
5317
+ e.preventDefault();
5318
+ return addRange(row, column.id, { row, columnId: column.id });
5319
+ }
5320
+ if (e.key === "Escape" && hasSelection) {
5321
+ e.preventDefault();
5322
+ return clearSel();
5323
+ }
5324
+ }
5325
+ const extend = selectable && e.shiftKey && row >= 0;
5227
5326
  const go = (r, i) => {
5228
5327
  e.preventDefault();
5229
- moveTo(r, colIds[Math.max(0, Math.min(i, colIds.length - 1))]);
5328
+ const target = colIds[Math.max(0, Math.min(i, colIds.length - 1))];
5329
+ if (extend) return extendSelection(row, column.id, r, target);
5330
+ if (hasSelection) clearSel();
5331
+ moveTo(r, target);
5230
5332
  };
5231
5333
  switch (e.key) {
5232
5334
  case "ArrowRight":
@@ -5270,6 +5372,7 @@ function DataGrid({
5270
5372
  role: "grid",
5271
5373
  "aria-rowcount": rowCount + 1,
5272
5374
  "aria-colcount": colIds.length,
5375
+ "aria-multiselectable": selectable || void 0,
5273
5376
  className: cn("relative overflow-auto rounded-md border font-sans text-sm", className),
5274
5377
  style: { height },
5275
5378
  onScroll: (e) => setScrollTop(e.currentTarget.scrollTop),
@@ -5346,19 +5449,48 @@ function DataGrid({
5346
5449
  const width = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
5347
5450
  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
5451
  const isEditing = editing?.rowIndex === rowIndex && editing.columnId === column.id;
5452
+ const selected = selectable && isSelectedCell(rowIndex, colIdx);
5349
5453
  return /* @__PURE__ */ jsx89(
5350
5454
  "div",
5351
5455
  {
5352
5456
  role: "gridcell",
5353
5457
  "aria-colindex": colIdx + 1,
5458
+ "aria-selected": selectable ? selected : void 0,
5354
5459
  "data-cell": `${rowIndex}:${column.id}`,
5460
+ onMouseDown: (e) => {
5461
+ if (!selectable || e.button !== 0 || isEditing) return;
5462
+ const here = { row: rowIndex, columnId: column.id };
5463
+ if (e.shiftKey) {
5464
+ e.preventDefault();
5465
+ extendSelection(activeRow >= 0 ? activeRow : rowIndex, activeCol, rowIndex, column.id);
5466
+ } else if (e.ctrlKey || e.metaKey) {
5467
+ addRange(rowIndex, column.id, activeRow >= 0 ? { row: activeRow, columnId: activeCol } : void 0);
5468
+ dragRef.current = here;
5469
+ } else {
5470
+ if (hasSelection) clearSel();
5471
+ dragRef.current = here;
5472
+ }
5473
+ },
5474
+ onMouseEnter: (e) => {
5475
+ const start = dragRef.current;
5476
+ if (!selectable || !start) return;
5477
+ if (e.buttons !== 1) {
5478
+ dragRef.current = null;
5479
+ return;
5480
+ }
5481
+ if (start.row === rowIndex && start.columnId === column.id) return;
5482
+ setSel({ anchor: start, end: { row: rowIndex, columnId: column.id } });
5483
+ moveTo(rowIndex, column.id);
5484
+ },
5355
5485
  onDoubleClick: () => column.editable && setEditing({ rowIndex, columnId: column.id }),
5356
5486
  tabIndex: tabStop.row === rowIndex && tabStop.columnId === column.id ? 0 : -1,
5357
5487
  onFocus: () => setActive({ row: rowIndex, columnId: column.id }),
5358
5488
  onKeyDown: (e) => onNavKeyDown(e, rowIndex, column),
5359
5489
  className: cn(
5360
5490
  "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"
5491
+ column.pinned && "bg-background",
5492
+ selectable && "select-none",
5493
+ selected && "bg-accent"
5362
5494
  ),
5363
5495
  style: { width, ...pinnedStyle },
5364
5496
  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.20.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.20.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,20 @@ 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 DataGridRange {
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
+
35
+ /** The selection: the range being edited (`rows` / `columns`, the last one) plus every range in `ranges`. */
36
+ export interface DataGridSelection extends DataGridRange {
37
+ /** every selected range, in the order they were added — more than one after Ctrl/Cmd+click or Ctrl+Space */
38
+ ranges: DataGridRange[];
39
+ }
40
+
27
41
  export interface DataGridProps<TData> {
28
42
  columns: DataGridColumn<TData>[];
29
43
  data: TData[];
@@ -33,6 +47,15 @@ export interface DataGridProps<TData> {
33
47
  overscan?: number;
34
48
  getRowId?: (row: TData, index: number) => React.Key;
35
49
  className?: string;
50
+ /**
51
+ * Opt in to range selection: drag, or Shift+arrows / Shift+click, to extend a rectangle from the
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.
55
+ */
56
+ selectable?: boolean;
57
+ /** fires when the selected range changes; `null` when it is cleared */
58
+ onSelectionChange?: (selection: DataGridSelection | null) => void;
36
59
  }
37
60
 
38
61
  type SortState = { columnId: string; direction: "asc" | "desc" } | null;
@@ -61,6 +84,8 @@ function DataGrid<TData>({
61
84
  overscan = 6,
62
85
  getRowId,
63
86
  className,
87
+ selectable = false,
88
+ onSelectionChange,
64
89
  }: DataGridProps<TData>) {
65
90
  const columnById = React.useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
66
91
  const [order, setOrder] = React.useState(() => columns.map((c) => c.id));
@@ -203,6 +228,28 @@ function DataGrid<TData>({
203
228
  const pendingFocusRef = React.useRef<Pos | null>(null);
204
229
  const [announcement, setAnnouncement] = React.useState("");
205
230
 
231
+ // ── range selection (opt-in) ──────────────────────────────────────────────────────────────
232
+ type Anchor = { row: number; columnId: string };
233
+ type Sel = { anchor: Anchor; end: Anchor };
234
+ const [sel, setSel] = React.useState<Sel | null>(null);
235
+ /** ranges committed before the current one (Ctrl/Cmd+click, Ctrl+Space) */
236
+ const [extra, setExtra] = React.useState<Sel[]>([]);
237
+ const dragRef = React.useRef<Anchor | null>(null);
238
+ React.useEffect(() => {
239
+ const end = () => {
240
+ dragRef.current = null;
241
+ };
242
+ window.addEventListener("mouseup", end);
243
+ return () => window.removeEventListener("mouseup", end);
244
+ }, []);
245
+ const clearSel = () => {
246
+ setSel(null);
247
+ setExtra([]);
248
+ };
249
+ const onSelectionChangeRef = React.useRef(onSelectionChange);
250
+ onSelectionChangeRef.current = onSelectionChange;
251
+ const lastEmittedRef = React.useRef("null");
252
+
206
253
  const rowCount = sortedData.length;
207
254
  const colIds = renderedColumns.map((c) => c.id);
208
255
  const activeCol = colIds.includes(active.columnId) ? active.columnId : (colIds[0] ?? "");
@@ -211,6 +258,80 @@ function DataGrid<TData>({
211
258
  // the grid stays reachable by Tab
212
259
  const activeRendered = activeRow < 0 || (activeRow >= startIndex && activeRow < endIndex);
213
260
  const tabStop: Pos = { row: activeRendered ? activeRow : -1, columnId: activeCol };
261
+ const colKey = colIds.join("|");
262
+ const rects = React.useMemo(() => {
263
+ if (!selectable) return [];
264
+ const out: { r0: number; r1: number; c0: number; c1: number }[] = [];
265
+ for (const s of sel ? [...extra, sel] : extra) {
266
+ const a = colIds.indexOf(s.anchor.columnId);
267
+ const b = colIds.indexOf(s.end.columnId);
268
+ if (a < 0 || b < 0) continue;
269
+ out.push({ r0: Math.min(s.anchor.row, s.end.row), r1: Math.max(s.anchor.row, s.end.row), c0: Math.min(a, b), c1: Math.max(a, b) });
270
+ }
271
+ return out;
272
+ // eslint-disable-next-line react-hooks/exhaustive-deps
273
+ }, [selectable, sel, extra, colKey]);
274
+ const hasSelection = rects.length > 0;
275
+ const cellCount = rects.reduce((n, r) => n + (r.r1 - r.r0 + 1) * (r.c1 - r.c0 + 1), 0);
276
+ const isSelectedCell = (row: number, colIdx: number) => rects.some((r) => row >= r.r0 && row <= r.r1 && colIdx >= r.c0 && colIdx <= r.c1);
277
+
278
+ // Selection refers to displayed rows, so a re-sort or a different row count invalidates it.
279
+ React.useEffect(() => {
280
+ setSel(null);
281
+ setExtra([]);
282
+ }, [sort, rowCount]);
283
+
284
+ React.useEffect(() => {
285
+ if (!selectable) return;
286
+ const ranges: DataGridRange[] = rects.map((r) => ({ rows: [r.r0, r.r1] as [number, number], columns: colIds.slice(r.c0, r.c1 + 1) }));
287
+ const value: DataGridSelection | null = ranges.length ? { ...ranges[ranges.length - 1]!, ranges } : null;
288
+ const key = JSON.stringify(value);
289
+ if (key === lastEmittedRef.current) return;
290
+ lastEmittedRef.current = key;
291
+ onSelectionChangeRef.current?.(value);
292
+ setAnnouncement(value ? `${cellCount} cells selected` : "Selection cleared");
293
+ // eslint-disable-next-line react-hooks/exhaustive-deps
294
+ }, [selectable, rects]);
295
+
296
+ function extendSelection(fromRow: number, fromCol: string, toRow: number, toCol: string) {
297
+ const anchor = sel?.anchor ?? { row: fromRow, columnId: fromCol };
298
+ const end = { row: Math.max(0, Math.min(toRow, rowCount - 1)), columnId: toCol };
299
+ setSel({ anchor, end });
300
+ moveTo(end.row, end.columnId);
301
+ }
302
+
303
+ /** Start a new range at a cell, keeping the current one (the active cell counts when nothing is selected yet). */
304
+ function addRange(row: number, columnId: string, prev?: Anchor) {
305
+ const previous = sel ?? (prev ? { anchor: prev, end: prev } : null);
306
+ if (previous) setExtra((x) => [...x, previous]);
307
+ setSel({ anchor: { row, columnId }, end: { row, columnId } });
308
+ }
309
+
310
+ function selectAll() {
311
+ if (rowCount === 0 || colIds.length === 0) return;
312
+ setExtra([]);
313
+ setSel({ anchor: { row: 0, columnId: colIds[0]! }, end: { row: rowCount - 1, columnId: colIds[colIds.length - 1]! } });
314
+ }
315
+
316
+ function copySelection() {
317
+ if (!hasSelection) return;
318
+ const text = rects
319
+ .map((r) => {
320
+ const ids = colIds.slice(r.c0, r.c1 + 1);
321
+ return sortedData
322
+ .slice(r.r0, r.r1 + 1)
323
+ .map((row) => ids.map((id) => { const c = columnById.get(id); return c?.value ? String(c.value(row)) : ""; }).join("\t"))
324
+ .join("\n");
325
+ })
326
+ .join("\n\n");
327
+ try {
328
+ void navigator.clipboard?.writeText(text);
329
+ setAnnouncement(`Copied ${cellCount} cells`);
330
+ } catch {
331
+ /* clipboard unavailable (insecure context / permission) — nothing to announce */
332
+ }
333
+ }
334
+
214
335
  const pageRows = Math.max(1, Math.floor((viewportHeight - HEADER_HEIGHT) / rowHeight) - 1);
215
336
  const labelOf = (c: DataGridColumn<TData>) => (typeof c.header === "string" ? c.header : c.id);
216
337
 
@@ -295,9 +416,33 @@ function DataGrid<TData>({
295
416
  return;
296
417
  }
297
418
 
419
+ const mod = e.ctrlKey || e.metaKey;
420
+ if (selectable && row >= 0) {
421
+ if (mod && e.key.toLowerCase() === "a") {
422
+ e.preventDefault();
423
+ return selectAll();
424
+ }
425
+ if (mod && e.key.toLowerCase() === "c" && hasSelection) {
426
+ e.preventDefault();
427
+ return copySelection();
428
+ }
429
+ if (mod && e.key === " ") {
430
+ // keyboard equivalent of Ctrl/Cmd+click: keep what is selected and start another range here
431
+ e.preventDefault();
432
+ return addRange(row, column.id, { row, columnId: column.id });
433
+ }
434
+ if (e.key === "Escape" && hasSelection) {
435
+ e.preventDefault();
436
+ return clearSel();
437
+ }
438
+ }
439
+ const extend = selectable && e.shiftKey && row >= 0;
298
440
  const go = (r: number, i: number) => {
299
441
  e.preventDefault();
300
- moveTo(r, colIds[Math.max(0, Math.min(i, colIds.length - 1))]!);
442
+ const target = colIds[Math.max(0, Math.min(i, colIds.length - 1))]!;
443
+ if (extend) return extendSelection(row, column.id, r, target);
444
+ if (hasSelection) clearSel(); // a plain move collapses the selection
445
+ moveTo(r, target);
301
446
  };
302
447
  switch (e.key) {
303
448
  case "ArrowRight":
@@ -341,6 +486,7 @@ function DataGrid<TData>({
341
486
  role="grid"
342
487
  aria-rowcount={rowCount + 1}
343
488
  aria-colcount={colIds.length}
489
+ aria-multiselectable={selectable || undefined}
344
490
  className={cn("relative overflow-auto rounded-md border font-sans text-sm", className)}
345
491
  style={{ height }}
346
492
  onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
@@ -434,12 +580,41 @@ function DataGrid<TData>({
434
580
  ? { position: "sticky", right: rightOffsets[column.id], zIndex: 1 }
435
581
  : {};
436
582
  const isEditing = editing?.rowIndex === rowIndex && editing.columnId === column.id;
583
+ const selected = selectable && isSelectedCell(rowIndex, colIdx);
437
584
  return (
438
585
  <div
439
586
  key={column.id}
440
587
  role="gridcell"
441
588
  aria-colindex={colIdx + 1}
589
+ aria-selected={selectable ? selected : undefined}
442
590
  data-cell={`${rowIndex}:${column.id}`}
591
+ onMouseDown={(e) => {
592
+ if (!selectable || e.button !== 0 || isEditing) return;
593
+ const here = { row: rowIndex, columnId: column.id };
594
+ if (e.shiftKey) {
595
+ // extend from the previously active cell (focus has not moved yet on mousedown) — even when it has
596
+ // since scrolled out of the rendered window
597
+ e.preventDefault();
598
+ extendSelection(activeRow >= 0 ? activeRow : rowIndex, activeCol, rowIndex, column.id);
599
+ } else if (e.ctrlKey || e.metaKey) {
600
+ addRange(rowIndex, column.id, activeRow >= 0 ? { row: activeRow, columnId: activeCol } : undefined);
601
+ dragRef.current = here; // Ctrl+drag sizes the new range
602
+ } else {
603
+ if (hasSelection) clearSel();
604
+ dragRef.current = here; // a plain click selects nothing until the pointer reaches another cell
605
+ }
606
+ }}
607
+ onMouseEnter={(e) => {
608
+ const start = dragRef.current;
609
+ if (!selectable || !start) return;
610
+ if (e.buttons !== 1) {
611
+ dragRef.current = null; // the button was released outside the grid
612
+ return;
613
+ }
614
+ if (start.row === rowIndex && start.columnId === column.id) return;
615
+ setSel({ anchor: start, end: { row: rowIndex, columnId: column.id } });
616
+ moveTo(rowIndex, column.id); // the dragged-to cell becomes the active one, so Shift+arrows continue from it
617
+ }}
443
618
  onDoubleClick={() => column.editable && setEditing({ rowIndex, columnId: column.id })}
444
619
  // roving tabindex; Enter or F2 edits an editable cell (the spreadsheet convention)
445
620
  tabIndex={tabStop.row === rowIndex && tabStop.columnId === column.id ? 0 : -1}
@@ -448,6 +623,8 @@ function DataGrid<TData>({
448
623
  className={cn(
449
624
  "flex shrink-0 items-center px-2 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
450
625
  column.pinned && "bg-background",
626
+ selectable && "select-none",
627
+ selected && "bg-accent",
451
628
  )}
452
629
  style={{ width, ...pinnedStyle }}
453
630
  >
@@ -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,239 @@ 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"], ranges: [{ 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"], ranges: [{ rows: [0, targetRow], columns: ["name"] }] });
753
+ });
754
+
755
+ it("Ctrl+click adds a separate range and keeps the first, reporting both", async () => {
756
+ const user = userEvent.setup();
757
+ const onSelectionChange = vi.fn();
758
+ setup({ selectable: true, onSelectionChange });
759
+ await user.tab();
760
+ await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{/Shift}"); // Name, rows 0-1
761
+ await user.keyboard("{Control>}");
762
+ await user.click(cell(2, 1)); // ID, row 2
763
+ await user.keyboard("{/Control}");
764
+ expect(selectedCells()).toHaveLength(3);
765
+ expect(cell(0, 0)).toHaveAttribute("aria-selected", "true");
766
+ expect(cell(2, 1)).toHaveAttribute("aria-selected", "true");
767
+ expect(cell(2, 0)).toHaveAttribute("aria-selected", "false");
768
+ expect(onSelectionChange).toHaveBeenLastCalledWith({
769
+ rows: [2, 2],
770
+ columns: ["id"],
771
+ ranges: [
772
+ { rows: [0, 1], columns: ["name"] },
773
+ { rows: [2, 2], columns: ["id"] },
774
+ ],
775
+ });
776
+ expect(screen.getByRole("status")).toHaveTextContent("3 cells selected");
777
+ });
778
+
779
+ it("Ctrl+click with nothing selected keeps the active cell as the first range", async () => {
780
+ const user = userEvent.setup();
781
+ setup({ selectable: true });
782
+ await user.tab();
783
+ await user.keyboard("{ArrowDown}"); // active: row 0, Name — not selected yet
784
+ await user.keyboard("{Control>}");
785
+ await user.click(cell(2, 1));
786
+ await user.keyboard("{/Control}");
787
+ expect(selectedCells()).toHaveLength(2);
788
+ expect(cell(0, 0)).toHaveAttribute("aria-selected", "true");
789
+ });
790
+
791
+ it("Ctrl+Space is the keyboard way to add a range; Shift+arrows then extends the new one", async () => {
792
+ const user = userEvent.setup();
793
+ const onSelectionChange = vi.fn();
794
+ setup({ selectable: true, onSelectionChange });
795
+ await user.tab();
796
+ await user.keyboard("{ArrowDown}"); // row 0, Name
797
+ await user.keyboard("{Control>} {/Control}"); // range 1 = this cell
798
+ await user.keyboard("{ArrowDown}{ArrowDown}"); // plain arrows collapse everything...
799
+ expect(selectedCells()).toHaveLength(0);
800
+ await user.keyboard("{Control>} {/Control}"); // ...so start over at row 2
801
+ await user.keyboard("{ArrowUp}");
802
+ expect(selectedCells()).toHaveLength(0);
803
+ // build two ranges without collapsing: Shift+arrows extends, Ctrl+Space branches
804
+ await user.keyboard("{ArrowUp}{Shift>}{ArrowDown}{/Shift}"); // rows 0-1
805
+ await user.keyboard("{Control>} {/Control}"); // second range starts at the active cell (row 1)
806
+ await user.keyboard("{Shift>}{ArrowDown}{ArrowRight}{/Shift}"); // rows 1-2, Name+ID
807
+ expect(onSelectionChange).toHaveBeenLastCalledWith({
808
+ rows: [1, 2],
809
+ columns: ["name", "id"],
810
+ ranges: [
811
+ { rows: [0, 1], columns: ["name"] },
812
+ { rows: [1, 2], columns: ["name", "id"] },
813
+ ],
814
+ });
815
+ });
816
+
817
+ it("Ctrl+C copies every range, separated by a blank line", async () => {
818
+ const user = userEvent.setup();
819
+ const writeText = vi.fn().mockResolvedValue(undefined);
820
+ Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
821
+ setup({ selectable: true });
822
+ await user.tab();
823
+ await user.keyboard("{ArrowDown}"); // Cara
824
+ await user.keyboard("{Control>}");
825
+ await user.click(cell(2, 1)); // 3
826
+ await user.keyboard("{/Control}");
827
+ await user.keyboard("{Control>}c{/Control}");
828
+ expect(writeText).toHaveBeenCalledWith("Cara\n\n3");
829
+ expect(screen.getByRole("status")).toHaveTextContent("Copied 2 cells");
830
+ });
831
+
832
+ it("dragging across cells selects the rectangle; a plain click selects nothing", async () => {
833
+ const user = userEvent.setup();
834
+ const onSelectionChange = vi.fn();
835
+ setup({ selectable: true, onSelectionChange });
836
+ await user.pointer({ keys: "[MouseLeft>]", target: cell(0, 0) });
837
+ expect(selectedCells()).toHaveLength(0); // pressed but not moved
838
+ await user.pointer({ target: cell(1, 1) });
839
+ await user.pointer({ keys: "[/MouseLeft]" });
840
+ expect(selectedCells()).toHaveLength(4);
841
+ expect(onSelectionChange).toHaveBeenLastCalledWith({ rows: [0, 1], columns: ["name", "id"], ranges: [{ rows: [0, 1], columns: ["name", "id"] }] });
842
+ expect(cell(1, 1)).toHaveFocus(); // Shift+arrows continue from the end
843
+ });
844
+
845
+ it("moving over cells with no button held does not select", async () => {
846
+ const user = userEvent.setup();
847
+ setup({ selectable: true });
848
+ await user.pointer({ keys: "[MouseLeft>]", target: cell(0, 0) });
849
+ await user.pointer({ keys: "[/MouseLeft]" });
850
+ await user.pointer({ target: cell(2, 1) });
851
+ expect(selectedCells()).toHaveLength(0);
852
+ });
853
+
854
+ it("re-sorting clears the selection (it refers to displayed rows)", async () => {
855
+ const user = userEvent.setup();
856
+ setup({ selectable: true });
857
+ await user.tab();
858
+ await user.keyboard("{ArrowDown}{Shift>}{ArrowDown}{/Shift}");
859
+ expect(selectedCells().length).toBeGreaterThan(0);
860
+ await user.keyboard("{ArrowUp}{ArrowUp}{Enter}"); // Name header, sort
861
+ expect(selectedCells()).toHaveLength(0);
862
+ });
863
+ });
864
+
615
865
  describe("with many rows (virtualized)", () => {
616
866
  const many: Row[] = Array.from({ length: 200 }, (_, i) => ({ id: i + 1, name: `Person ${i + 1}` }));
617
867
 
package/src/index.ts CHANGED
@@ -372,7 +372,7 @@ export { MultiSelect, type MultiSelectOption, type MultiSelectProps } from "./co
372
372
  export { MessageBubble, TypingIndicator, type MessageBubbleProps } from "./components/message-bubble";
373
373
  export { Tour, type TourStep, type TourProps } from "./components/tour";
374
374
  export { VirtualList, type VirtualListProps } from "./components/virtual-list";
375
- export { DataGrid, type DataGridColumn, type DataGridProps } from "./components/data-grid";
375
+ export { DataGrid, type DataGridColumn, type DataGridProps, type DataGridRange, type DataGridSelection } from "./components/data-grid";
376
376
  export { JsonViewer, type JsonViewerProps } from "./components/json-viewer";
377
377
  export { DiffViewer, type DiffViewerProps } from "./components/diff-viewer";
378
378
  export { ColorPicker, type ColorPickerProps } from "./components/color-picker";
@@ -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