@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 +23 -2
- package/dist/index.js +135 -3
- package/package.json +2 -2
- package/src/components/data-grid.tsx +178 -1
- package/src/components-keyboard.test.tsx +253 -3
- package/src/index.ts +1 -1
- package/src/stories/DataGrid.stories.tsx +2 -1
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
437
|
+
function setup(
|
|
438
|
+
opts: {
|
|
439
|
+
onCellEdit?: ReturnType<typeof vi.fn>;
|
|
440
|
+
data?: Row[];
|
|
441
|
+
wrapper?: (n: React.ReactNode) => React.ReactNode;
|
|
442
|
+
selectable?: boolean;
|
|
443
|
+
onSelectionChange?: (s: DataGridSelection | null) => void;
|
|
444
|
+
} = {},
|
|
445
|
+
) {
|
|
438
446
|
const onCellEdit = opts.onCellEdit ?? vi.fn();
|
|
439
447
|
const columns: DataGridColumn<Row>[] = [
|
|
440
448
|
{ id: "name", header: "Name", cell: (r) => r.name, value: (r) => r.name, sortable: true, editable: true, onCellEdit },
|
|
441
449
|
{ id: "id", header: "ID", cell: (r) => r.id, value: (r) => r.id, minWidth: 60, width: 100 },
|
|
442
450
|
];
|
|
443
|
-
const grid =
|
|
451
|
+
const grid = (
|
|
452
|
+
<DataGrid
|
|
453
|
+
columns={columns}
|
|
454
|
+
data={opts.data ?? rows}
|
|
455
|
+
height={200}
|
|
456
|
+
getRowId={(r) => r.id}
|
|
457
|
+
selectable={opts.selectable}
|
|
458
|
+
onSelectionChange={opts.onSelectionChange}
|
|
459
|
+
/>
|
|
460
|
+
);
|
|
444
461
|
render(
|
|
445
462
|
<>
|
|
446
463
|
{opts.wrapper ? opts.wrapper(grid) : grid}
|
|
@@ -612,6 +629,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
|
|