diff --git a/examples/src/pages/tests/table/dnd/dnd-reorder.page.tsx b/examples/src/pages/tests/table/dnd/dnd-reorder.page.tsx new file mode 100644 index 00000000..e0712671 --- /dev/null +++ b/examples/src/pages/tests/table/dnd/dnd-reorder.page.tsx @@ -0,0 +1,426 @@ +import * as React from 'react'; + +import { DragList, DragDropProvider } from '@infinite-table/infinite-react'; + +type Task = { + id: number; + title: string; + priority: 'low' | 'medium' | 'high' | 'critical'; + assignee: string; + status: string; +}; + +const PRIORITIES: Task['priority'][] = ['low', 'medium', 'high', 'critical']; +const STATUSES = [ + 'Backlog', + 'To Do', + 'In Progress', + 'In Review', + 'Done', + 'Deployed', +]; +const ASSIGNEES = [ + 'Alice Chen', + 'Bob Martinez', + 'Carol Wu', + 'David Kim', + 'Eva Patel', + 'Frank Okafor', + 'Grace Lee', + 'Hassan Ali', +]; +const TASK_TITLES = [ + 'Fix login redirect loop', + 'Add dark mode toggle', + 'Migrate to new API v3', + 'Update onboarding flow', + 'Optimize image pipeline', + 'Add export to CSV', + 'Fix timezone bug in calendar', + 'Implement SSO integration', + 'Redesign settings page', + 'Add rate limiting middleware', + 'Fix memory leak in websocket', + 'Create admin dashboard', + 'Add two-factor auth', + 'Refactor payment module', + 'Improve search relevance', + 'Add push notifications', + 'Fix CORS issue on staging', + 'Add audit logging', + 'Optimize database queries', + 'Migrate to TypeScript strict', + 'Add E2E test suite', + 'Fix broken pagination', + 'Add role-based permissions', + 'Implement file upload', + 'Fix date picker on Safari', + 'Add keyboard shortcuts', + 'Optimize bundle size', + 'Add analytics dashboard', + 'Fix scroll position on nav', + 'Add batch operations', + 'Improve error messages', + 'Add auto-save for drafts', + 'Fix email template rendering', + 'Add multi-language support', + 'Implement undo/redo', + 'Fix race condition in checkout', + 'Add custom theme builder', + 'Optimize API response times', + 'Add data validation layer', + 'Fix accessibility violations', + 'Add webhook integrations', + 'Implement lazy loading', + 'Fix PDF export alignment', + 'Add real-time collaboration', + 'Optimize CSS bundle', + 'Add user activity feed', + 'Fix session timeout handling', + 'Add drag-and-drop upload', + 'Implement cache invalidation', + 'Add status page monitoring', +]; + +function generateTasks(count: number): Task[] { + return Array.from({ length: count }, (_, i) => ({ + id: i + 1, + title: TASK_TITLES[i % TASK_TITLES.length], + priority: PRIORITIES[i % PRIORITIES.length], + assignee: ASSIGNEES[i % ASSIGNEES.length], + status: STATUSES[i % STATUSES.length], + })); +} + +const PRIORITY_COLORS: Record = { + low: '#94a3b8', + medium: '#3b82f6', + high: '#f59e0b', + critical: '#ef4444', +}; + +const PRIORITY_BG: Record = { + low: '#f1f5f9', + medium: '#eff6ff', + high: '#fffbeb', + critical: '#fef2f2', +}; + +function TaskRow(props: { + task: Task; + active: boolean; + draggingInProgress: boolean; + domProps: React.HTMLProps; + index: number; +}) { + const { task, active, draggingInProgress, domProps, index } = props; + const { onPointerDown, className: _cls, ...restDomProps } = domProps; + const priorityColor = PRIORITY_COLORS[task.priority]; + const priorityBg = PRIORITY_BG[task.priority]; + + return ( +
+ {/* Drag handle */} +
+ ⠿ +
+ + {/* Row number */} +
+ {index + 1} +
+ + {/* Priority badge */} +
+ {task.priority} +
+ + {/* Title */} +
+ {task.title} +
+ + {/* Assignee */} +
+ {task.assignee} +
+ + {/* Status */} +
+ {task.status} +
+ + {/* ID */} +
+ #{task.id} +
+
+ ); +} + +export default function DndReorderExample() { + const [tasks, setTasks] = React.useState(() => generateTasks(50)); + const [strategy, setStrategy] = React.useState<'inline' | 'proxy'>('proxy'); + + const onDrop = React.useCallback((sortedIndexes: number[]) => { + setTasks((prev) => sortedIndexes.map((i) => prev[i])); + }, []); + + return ( + + {({ dropTargetListId }) => { + const isActive = dropTargetListId === 'tasks'; + + return ( +
+

+ Drag & Drop: Reorderable List +

+

+ Drag items by the handle on the left to reorder. The list is + scrollable — try dragging items across the visible area. +

+ +
+ {(['inline', 'proxy'] as const).map((s) => ( + + ))} + + Strategy: {strategy} — proxy creates a floating clone not + clipped by overflow + +
+ +
+ {/* Table header */} +
+
+
#
+
Priority
+
Task
+
Assignee
+
Status
+
ID
+
+ + + {(domProps) => ( +
+ {tasks.map((task, index) => ( + + {(itemDomProps, { active, draggingInProgress }) => ( + + )} + + ))} +
+ )} +
+ + {/* Footer */} +
+ {tasks.length} tasks + Drag to reorder +
+
+
+ ); + }} + + ); +} diff --git a/examples/src/pages/tests/table/dnd/dnd-source-target.page.tsx b/examples/src/pages/tests/table/dnd/dnd-source-target.page.tsx new file mode 100644 index 00000000..c5b47fdf --- /dev/null +++ b/examples/src/pages/tests/table/dnd/dnd-source-target.page.tsx @@ -0,0 +1,676 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +import { + DragList, + DragDropProvider, + type DragProxyRenderParams, +} from '@infinite-table/infinite-react'; + +type Item = { + id: number; + label: string; + category: string; + color: string; +}; + +const COLORS = [ + '#6366f1', + '#8b5cf6', + '#a855f7', + '#d946ef', + '#ec4899', + '#f43f5e', + '#ef4444', + '#f97316', + '#eab308', + '#84cc16', + '#22c55e', + '#14b8a6', + '#06b6d4', + '#3b82f6', + '#2563eb', +]; + +const CATEGORIES = [ + 'Fruit', + 'Vegetable', + 'Dairy', + 'Grain', + 'Protein', + 'Snack', + 'Beverage', + 'Spice', +]; + +const ITEM_NAMES = [ + 'Apple', + 'Banana', + 'Carrot', + 'Donut', + 'Eggplant', + 'Fig', + 'Grape', + 'Hummus', + 'Ice Cream', + 'Jalapeño', + 'Kiwi', + 'Lemon', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Quinoa', + 'Radish', + 'Spinach', + 'Tomato', + 'Udon', + 'Vanilla', + 'Walnut', + 'Xigua', + 'Yogurt', + 'Zucchini', + 'Avocado', + 'Blueberry', + 'Coconut', + 'Dragonfruit', +]; + +function generateItems(count: number, startId = 1): Item[] { + return Array.from({ length: count }, (_, i) => ({ + id: startId + i, + label: ITEM_NAMES[i % ITEM_NAMES.length], + category: CATEGORIES[i % CATEGORIES.length], + color: COLORS[i % COLORS.length], + })); +} + +const SOURCE_ITEMS = generateItems(30, 1); +const SOURCE2_ITEMS = generateItems(10, 100); + +function ItemCard(props: { + item: Item; + active: boolean; + draggingInProgress: boolean; + domProps: React.HTMLProps; + showHandle?: boolean; +}) { + const { + item, + active, + draggingInProgress, + domProps, + showHandle = true, + } = props; + const { onPointerDown, className: _cls, ...restDomProps } = domProps; + + return ( +
+ {showHandle && ( +
+ ⠿ +
+ )} +
+
+ {item.label} +
+
{item.category}
+
+
+ #{item.id} +
+
+ ); +} + +function ListHeader(props: { title: string; count: number; accent: string }) { + return ( +
+ + {props.title} + + + {props.count} + +
+ ); +} + +function ProxyCard({ + item, + style, + proxyRef, +}: { + item: Item; + style: React.CSSProperties; + proxyRef: React.RefCallback; +}) { + return ( +
+
+ ⠿ +
+
+
+ {item.label} +
+
{item.category}
+
+
+ Moving +
+
+ ); +} + +function makeRenderDragProxy(getItems: () => Item[]) { + return ({ dragItemId, initialRect, dx, dy, ref }: DragProxyRenderParams) => { + if (!dragItemId) return null; + + const item = getItems().find((i) => `${i.id}` === dragItemId); + if (!item) return null; + + return createPortal( + , + document.body, + ); + }; +} + +export default function DndSourceTargetExample() { + const [sourceItems, setSourceItems] = React.useState(SOURCE_ITEMS); + const [source2Items, setSource2Items] = React.useState(SOURCE2_ITEMS); + const [targetItems, setTargetItems] = React.useState([]); + + const onDropSource = React.useCallback((sortedIndexes: number[]) => { + setSourceItems((prev) => sortedIndexes.map((i) => prev[i])); + }, []); + + const onRemoveSource = React.useCallback((sortedIndexes: number[]) => { + setSourceItems((prev) => sortedIndexes.map((i) => prev[i])); + }, []); + + const onDropSource2 = React.useCallback((sortedIndexes: number[]) => { + setSource2Items((prev) => sortedIndexes.map((i) => prev[i])); + }, []); + + const onRemoveSource2 = React.useCallback((sortedIndexes: number[]) => { + setSource2Items((prev) => sortedIndexes.map((i) => prev[i])); + }, []); + + const onDropTarget = React.useCallback((sortedIndexes: number[]) => { + setTargetItems((prev) => sortedIndexes.map((i) => prev[i])); + }, []); + + const onRemoveTarget = React.useCallback((sortedIndexes: number[]) => { + setTargetItems((prev) => sortedIndexes.map((i) => prev[i])); + }, []); + + const onAcceptDropTarget = React.useCallback( + (params: { + dragItemId: string; + dragSourceListId: string; + dragIndex: number; + dropIndex: number; + }) => { + const allSourceItems = + params.dragSourceListId === 'source' ? sourceItems : source2Items; + const draggedItem = allSourceItems.find( + (item) => `${item.id}` === params.dragItemId, + ); + if (!draggedItem) return; + + setTargetItems((prev) => { + const newItems = [...prev]; + newItems.splice(params.dropIndex, 0, draggedItem); + return newItems; + }); + }, + [sourceItems, source2Items], + ); + + const onAcceptDropSource = React.useCallback( + (params: { dragItemId: string; dragIndex: number; dropIndex: number }) => { + const draggedItem = targetItems.find( + (item) => `${item.id}` === params.dragItemId, + ); + if (!draggedItem) return; + + setSourceItems((prev) => { + const newItems = [...prev]; + newItems.splice(params.dropIndex, 0, draggedItem); + return newItems; + }); + }, + [targetItems], + ); + + const renderSourceProxy = React.useMemo( + () => makeRenderDragProxy(() => sourceItems), + [sourceItems], + ); + + const renderTargetProxy = React.useMemo( + () => makeRenderDragProxy(() => targetItems), + [targetItems], + ); + + return ( + + {({ dragSourceListId, dropTargetListId, status }) => { + const rejected = status === 'rejected'; + + return ( +
+

+ Drag & Drop: Source ↔ Target +

+

+ Drag items between the two lists. Items are removed from the + source list and added to the target. +

+ +
+ {/* SOURCE LIST */} +
+ + { + // dragItemNode.style.visibility = 'visible'; + // }} + > + {(domProps) => { + const isDropTarget = dropTargetListId === 'source'; + return ( +
+ {sourceItems.map((item) => ( + + {(itemDomProps, { active, draggingInProgress }) => ( + + )} + + ))} + {sourceItems.length === 0 && ( +
+ All items moved to target +
+ )} +
+ ); + }} +
+
+ + {/* ARROW */} +
+ ⇄ +
+ + {/* TARGET LIST */} +
+ + + {(domProps) => ( +
+ {targetItems.map((item) => ( + + {(itemDomProps, { active, draggingInProgress }) => ( + + )} + + ))} + {targetItems.length === 0 && ( +
+ Drop items here +
+ )} +
+ )} +
+
+ + {/* SOURCE 2 — preserveDragSpace, item stays visible */} +
+ + { + proxyElement.style.opacity = '0.85'; + proxyElement.style.boxShadow = '0 8px 24px rgba(0,0,0,0.2)'; + // queueMicrotask(() => { + // dragItemNode.style.visibility = 'visible'; + // }); + }} + > + {(domProps) => { + const isDropTarget = dropTargetListId === 'source2'; + return ( +
+ {source2Items.map((item) => ( + + {(itemDomProps, { active, draggingInProgress }) => ( + + )} + + ))} + {source2Items.length === 0 && ( +
+ All items moved to target +
+ )} +
+ ); + }} +
+
+
+
+ ); + }} +
+ ); +} diff --git a/examples/src/pages/tests/table/props/data/editing/column-editor.spec.ts b/examples/src/pages/tests/table/props/data/editing/column-editor.spec.ts index 0c056165..b5da1456 100644 --- a/examples/src/pages/tests/table/props/data/editing/column-editor.spec.ts +++ b/examples/src/pages/tests/table/props/data/editing/column-editor.spec.ts @@ -4,11 +4,11 @@ export default test.describe.parallel('Inline Edit', () => { page, editModel, rowModel, - tracingModel, + // tracingModel, }) => { await page.waitForInfinite(); - const stop = await tracingModel.start(); + // const stop = await tracingModel.start(); const cellEditable1 = { colId: 'firstName', @@ -45,6 +45,6 @@ export default test.describe.parallel('Inline Edit', () => { // make sure this second column was using default editor expect(await rowModel.getTextForCell(cellEditable2)).toBe('test'); - await stop(); + // await stop(); }); }); diff --git a/source/src/components/InfiniteTable/components/GroupingToolbar/index.tsx b/source/src/components/InfiniteTable/components/GroupingToolbar/index.tsx index b842c722..cf8be791 100644 --- a/source/src/components/InfiniteTable/components/GroupingToolbar/index.tsx +++ b/source/src/components/InfiniteTable/components/GroupingToolbar/index.tsx @@ -283,6 +283,7 @@ export function GroupingToolbar(props: GroupingToolbarProps) { dragListId={GROUPING_TOOLBAR_DRAG_LIST_ID} acceptDropsFrom={['header', GROUPING_TOOLBAR_DRAG_LIST_ID]} onDrop={onDrop} + dragStrategy="inline" onAcceptDrop={onAcceptDrop} shouldAcceptDrop={shouldAcceptDrop} > diff --git a/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeader.tsx b/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeader.tsx index 5eea883c..1196f6e7 100644 --- a/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeader.tsx +++ b/source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeader.tsx @@ -227,6 +227,7 @@ function InfiniteTableInternalHeaderFn( diff --git a/source/src/components/InfiniteTable/components/draggable/AutoScroller.ts b/source/src/components/InfiniteTable/components/draggable/AutoScroller.ts new file mode 100644 index 00000000..a752412e --- /dev/null +++ b/source/src/components/InfiniteTable/components/draggable/AutoScroller.ts @@ -0,0 +1,346 @@ +import { PointCoords } from '../../../../utils/pageGeometry/Point'; + +export type AutoScrollerConfig = { + // what percentage of container size defines the start of the scroll zone + // e.g. 0.25 = scrolling starts when within 25% of the edge + startFromPercentage: number; + // what percentage of container size defines where max scroll speed kicks in + maxScrollAtPercentage: number; + // max pixels scrolled per animation frame + maxPixelScroll: number; + // easing function applied to scroll speed based on proximity + ease: (percentage: number) => number; + // time dampening: prevents instant scrolling when grabbing near an edge + durationDampening: { + stopDampeningAt: number; + accelerateAt: number; + }; +}; + +export const defaultAutoScrollerConfig: AutoScrollerConfig = { + startFromPercentage: 0.25, + maxScrollAtPercentage: 0.05, + maxPixelScroll: 28, + ease: (pct: number) => pct ** 2, + durationDampening: { + stopDampeningAt: 1200, + accelerateAt: 360, + }, +}; + +function isScrollable( + element: HTMLElement, + orientation: 'horizontal' | 'vertical', +): boolean { + const style = getComputedStyle(element); + const overflowProp = + orientation === 'vertical' ? style.overflowY : style.overflowX; + + if (overflowProp !== 'auto' && overflowProp !== 'scroll') { + return false; + } + + return orientation === 'vertical' + ? element.scrollHeight > element.clientHeight + : element.scrollWidth > element.clientWidth; +} + +function findScrollableAncestor( + element: HTMLElement, + orientation: 'horizontal' | 'vertical', +): HTMLElement | null { + let current: HTMLElement | null = element; + + while (current) { + if (current === document.documentElement || current === document.body) { + break; + } + + if (isScrollable(current, orientation)) { + return current; + } + + current = current.parentElement; + } + + return null; +} + +interface DistanceThresholds { + startScrollingFrom: number; + maxScrollValueAt: number; +} + +function getThresholds( + containerSize: number, + config: AutoScrollerConfig, +): DistanceThresholds { + return { + startScrollingFrom: containerSize * config.startFromPercentage, + maxScrollValueAt: containerSize * config.maxScrollAtPercentage, + }; +} + +const MIN_SCROLL = 1; + +function getValueFromDistance( + distanceToEdge: number, + thresholds: DistanceThresholds, + config: AutoScrollerConfig, +): number { + if (distanceToEdge > thresholds.startScrollingFrom) { + return 0; + } + + if (distanceToEdge <= thresholds.maxScrollValueAt) { + return config.maxPixelScroll; + } + + if (distanceToEdge === thresholds.startScrollingFrom) { + return MIN_SCROLL; + } + + const percentFromMax = + (distanceToEdge - thresholds.maxScrollValueAt) / + (thresholds.startScrollingFrom - thresholds.maxScrollValueAt); + + const percentFromStart = 1 - percentFromMax; + + return Math.ceil(config.maxPixelScroll * config.ease(percentFromStart)); +} + +function dampenByTime( + proposedScroll: number, + dragStartTime: number, + config: AutoScrollerConfig, +): number { + const { accelerateAt, stopDampeningAt } = config.durationDampening; + const runTime = Date.now() - dragStartTime; + + if (runTime >= stopDampeningAt) { + return proposedScroll; + } + + if (runTime < accelerateAt) { + return MIN_SCROLL; + } + + const pct = (runTime - accelerateAt) / (stopDampeningAt - accelerateAt); + + return Math.ceil(proposedScroll * config.ease(pct)); +} + +function getScrollForAxis( + point: number, + containerStart: number, + containerEnd: number, + containerSize: number, + config: AutoScrollerConfig, + dragStartTime: number, + shouldUseDampening: boolean, +): number { + // no auto-scroll when the pointer is outside the container + if (point < containerStart || point > containerEnd) { + return 0; + } + + const thresholds = getThresholds(containerSize, config); + const distToStart = point - containerStart; + const distToEnd = containerEnd - point; + const closerToEnd = distToEnd < distToStart; + + let value: number; + if (closerToEnd) { + value = getValueFromDistance(distToEnd, thresholds, config); + } else { + value = -getValueFromDistance(distToStart, thresholds, config); + } + + if (value === 0) { + return 0; + } + + if (shouldUseDampening) { + const sign = value > 0 ? 1 : -1; + return sign * dampenByTime(Math.abs(value), dragStartTime, config); + } + + return value; +} + +export type AutoScrollerOnScroll = (scrollDelta: PointCoords) => void; + +export class AutoScroller { + private scrollContainer: HTMLElement | null = null; + private orientation: 'horizontal' | 'vertical'; + private config: AutoScrollerConfig; + private rafId: number | null = null; + private lastPointer: PointCoords = { left: 0, top: 0 }; + private dragStartTime: number = 0; + private shouldUseDampening: boolean = false; + private onScroll: AutoScrollerOnScroll; + private active: boolean = false; + + // captured at drag start before transforms inflate scrollHeight + private maxScrollTop: number = 0; + private maxScrollLeft: number = 0; + + constructor(options: { + orientation: 'horizontal' | 'vertical'; + onScroll: AutoScrollerOnScroll; + config?: Partial; + }) { + this.orientation = options.orientation; + this.onScroll = options.onScroll; + this.config = { ...defaultAutoScrollerConfig, ...options.config }; + } + + start(listElement: HTMLElement) { + this.scrollContainer = findScrollableAncestor( + listElement, + this.orientation, + ); + + if (this.scrollContainer) { + // Snapshot the real content extent before CSS transforms inflate it. + // During drag, transforms on the active item can extend scrollHeight, + // creating a feedback loop. Clamping to this snapshot prevents that. + this.maxScrollTop = + this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight; + this.maxScrollLeft = + this.scrollContainer.scrollWidth - this.scrollContainer.clientWidth; + } + + this.dragStartTime = Date.now(); + this.shouldUseDampening = true; + this.active = true; + } + + getScrollContainer(): HTMLElement | null { + return this.scrollContainer; + } + + updatePointer(point: PointCoords) { + this.lastPointer = point; + + if (!this.rafId && this.active) { + this.scheduleScroll(); + } + } + + private scheduleScroll() { + this.rafId = requestAnimationFrame(() => { + this.rafId = null; + + if (!this.active || !this.scrollContainer) { + return; + } + + const delta = this.computeScroll(); + if (delta.top === 0 && delta.left === 0) { + return; + } + + const scrollBefore = { + top: this.scrollContainer.scrollTop, + left: this.scrollContainer.scrollLeft, + }; + + // Clamp so we never scroll past the real content extent + // captured at drag start (before transforms inflated scrollHeight). + const clampedTop = Math.max( + 0, + Math.min(this.maxScrollTop, scrollBefore.top + delta.top), + ); + const clampedLeft = Math.max( + 0, + Math.min(this.maxScrollLeft, scrollBefore.left + delta.left), + ); + + this.scrollContainer.scrollTop = clampedTop; + this.scrollContainer.scrollLeft = clampedLeft; + + const actualDelta = { + top: this.scrollContainer.scrollTop - scrollBefore.top, + left: this.scrollContainer.scrollLeft - scrollBefore.left, + }; + + const didScroll = actualDelta.top !== 0 || actualDelta.left !== 0; + + if (didScroll) { + this.onScroll(actualDelta); + } + + // Only continue the loop if scrolling actually happened. + // When the container hits its scroll limit the browser clamps scrollTop, + // actualDelta becomes 0, and we stop. The next updatePointer() call + // will restart the loop if the user moves to a scrollable direction. + if (this.active && didScroll) { + this.scheduleScroll(); + } + }); + } + + private computeScroll(): PointCoords { + if (!this.scrollContainer) { + return { top: 0, left: 0 }; + } + + const rect = this.scrollContainer.getBoundingClientRect(); + + // Only scroll when the pointer is within the container on the cross axis. + // Without this, dragging an item off to the side of a list still triggers + // scrolling because the scroll-axis position is near an edge. + if (this.orientation === 'vertical') { + if ( + this.lastPointer.left < rect.left || + this.lastPointer.left > rect.right + ) { + return { top: 0, left: 0 }; + } + } else { + if ( + this.lastPointer.top < rect.top || + this.lastPointer.top > rect.bottom + ) { + return { top: 0, left: 0 }; + } + } + + let scrollTop = 0; + let scrollLeft = 0; + + if (this.orientation === 'vertical') { + scrollTop = getScrollForAxis( + this.lastPointer.top, + rect.top, + rect.bottom, + rect.height, + this.config, + this.dragStartTime, + this.shouldUseDampening, + ); + } else { + scrollLeft = getScrollForAxis( + this.lastPointer.left, + rect.left, + rect.right, + rect.width, + this.config, + this.dragStartTime, + this.shouldUseDampening, + ); + } + + return { top: scrollTop, left: scrollLeft }; + } + + stop() { + this.active = false; + if (this.rafId != null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + this.scrollContainer = null; + } +} diff --git a/source/src/components/InfiniteTable/components/draggable/DragInteractionTarget.ts b/source/src/components/InfiniteTable/components/draggable/DragInteractionTarget.ts index efaab4ee..3cb70af9 100644 --- a/source/src/components/InfiniteTable/components/draggable/DragInteractionTarget.ts +++ b/source/src/components/InfiniteTable/components/draggable/DragInteractionTarget.ts @@ -16,6 +16,7 @@ export type DragInteractionTargetData = { acceptDropsFrom?: string[]; shouldAcceptDrop?: (event: DragInteractionTargetMoveEvent) => boolean; initial: boolean; + preserveDragSpace?: boolean; }; export type DraggableItem = { id: string; @@ -141,6 +142,22 @@ export class DragInteractionTarget extends EventEmitter bp - scrollDelta); + + if (this.data.orientation === 'vertical') { + this.data.listRectangle.shift({ top: -scrollDelta, left: 0 }); + } else { + this.data.listRectangle.shift({ top: 0, left: -scrollDelta }); + } + } + move(params: DragInteractionTargetMoveEvent) { this.emit('move', params); } diff --git a/source/src/components/InfiniteTable/components/draggable/DragList.tsx b/source/src/components/InfiniteTable/components/draggable/DragList.tsx index f5af82fa..d4551e97 100644 --- a/source/src/components/InfiniteTable/components/draggable/DragList.tsx +++ b/source/src/components/InfiniteTable/components/draggable/DragList.tsx @@ -5,7 +5,7 @@ import { DragManager, DragOperation, } from './DragManager'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { DraggableItemRecipe, DragListRecipe } from './DragList.css'; import { join } from '../../../../utils/join'; import { Rectangle } from '../../../../utils/pageGeometry/Rectangle'; @@ -17,6 +17,7 @@ import { } from './DragInteractionTarget'; import { getGlobal } from '../../../../utils/getGlobal'; import { selectParent } from '../../../../utils/selectParent'; +import { AutoScroller } from './AutoScroller'; type DragListContextValue = { dragItemId: string | number | null; @@ -41,6 +42,45 @@ export const useDragListContext = () => { return React.useContext(DragListContext); }; +export type DragProxySetupParams = { + dragItemNode: HTMLElement; + dragItemId: string; + initialRect: DOMRect; + initialCoords: { left: number; top: number }; + /** + * Lazy getter. On first access it creates the default proxy: clones the + * drag item node, applies fixed-position styles, and appends it to + * document.body. Subsequent accesses return the same element. + */ + readonly proxyElement: HTMLElement; +}; + +export type DragProxySetupResult = { + /** If omitted, the default proxy (from params.proxyElement) is used. */ + proxyElement?: HTMLElement; + /** Called on drop/cancel with a reference to the proxy element. */ + cleanup?: (params: { proxyElement: HTMLElement }) => void; +}; + +export type DragProxyMoveParams = { + proxyElement: HTMLElement; + dx: number; + dy: number; +}; + +export type DragProxyRenderParams = { + /** + * The id of the item being dragged. `null` on the final cleanup call + * (drop/cancel) — return null to unmount the proxy. + */ + dragItemId: string | null; + initialRect: DOMRect; + dx: number; + dy: number; + /** Must be passed to the root element of the rendered proxy. */ + ref: React.RefCallback; +}; + export type DragListProps = { dragListId: string; children: ( @@ -82,6 +122,48 @@ export type DragListProps = { node: HTMLElement; offset: null | { left: number; top: number }; }) => void; + + /** + * Controls how the dragged item is rendered during a drag operation. + * + * - `'inline'`: the item stays in the DOM flow and moves via CSS transforms. + * - `'proxy'`: (default) a fixed-position clone is created outside scroll containers so it is + * never clipped by overflow. The original item is visually hidden during the drag. + */ + dragStrategy?: 'inline' | 'proxy'; + + /** + * Called once on the first pointer move to create the drag proxy. + * Access params.proxyElement to get (and lazily create) the default proxy, + * or build your own and return it. + * If the returned object omits proxyElement, the default is used. + * Only called when dragStrategy is 'proxy' and renderDragProxy is not provided. + */ + onDragProxySetup?: ( + params: DragProxySetupParams, + ) => DragProxySetupResult | void; + + /** + * Called on every pointer move to reposition the proxy. + * The default implementation sets transform: translate3d(dx, dy, 0). + * Only called when dragStrategy is 'proxy' and renderDragProxy is not provided. + */ + onDragProxyMove?: (params: DragProxyMoveParams) => void; + + /** + * Render a custom React element as the drag proxy. The function should + * call createPortal() itself and attach params.ref to the root element. + * When provided, onDragProxySetup and onDragProxyMove are ignored. + * The original DOM node is hidden automatically. + * Called with dragItemId: null on drop/cancel — return null to unmount. + */ + renderDragProxy?: (params: DragProxyRenderParams) => React.ReactNode; + + /** + * When true, the space occupied by the dragged item in the source list + * is preserved (not collapsed) while dragging outside the list. + */ + preserveDragSpace?: boolean; }; const defaultUpdatePosition: DragListProps['updatePosition'] = (options) => { @@ -125,19 +207,46 @@ function getInteractionTargetData(params: { acceptDropsFrom?: string[]; removeOnDropOutside?: boolean; shouldAcceptDrop?: (event: DragInteractionTargetMoveEvent) => boolean; + preserveDragSpace?: boolean; }): DragInteractionTargetData { const { domRef, orientation, dragListId } = params; - const listRectangle = Rectangle.from(domRef.current!.getBoundingClientRect()); + const draggableItems = getDraggableItems(domRef); + const domRect = domRef.current!.getBoundingClientRect(); + + // Expand the list rectangle to encompass all draggable items, + // including those scrolled out of view. This is necessary because + // during drag+scroll the pointer position is adjusted by scroll offset, + // and the containment check must still pass for those virtual positions. + let top = domRect.top; + let left = domRect.left; + let bottom = domRect.bottom; + let right = domRect.right; + + for (const item of draggableItems) { + const r = item.rect; + if (r.top < top) top = r.top; + if (r.left < left) left = r.left; + if (r.bottom > bottom) bottom = r.bottom; + if (r.right > right) right = r.right; + } + + const listRectangle = Rectangle.from({ + top, + left, + width: right - left, + height: bottom - top, + }); const options: DragInteractionTargetData = { - draggableItems: getDraggableItems(domRef), + draggableItems, orientation, listId: dragListId, listRectangle, acceptDropsFrom: params.acceptDropsFrom, shouldAcceptDrop: params.shouldAcceptDrop, initial: params.initial, + preserveDragSpace: params.preserveDragSpace, }; return options; @@ -221,8 +330,72 @@ function useInteractionTarget( }; } +/** + * Creates the default proxy element: clones the node, applies fixed-position + * styles, and appends it to document.body. + */ +export function createDefaultProxy( + dragItemNode: HTMLElement, + initialRect: DOMRect, +): HTMLElement { + const proxy = dragItemNode.cloneNode(true) as HTMLElement; + proxy.removeAttribute(DRAG_ITEM_ATTRIBUTE); + proxy.style.position = 'fixed'; + proxy.style.top = `${initialRect.top}px`; + proxy.style.left = `${initialRect.left}px`; + proxy.style.width = `${initialRect.width}px`; + proxy.style.height = `${initialRect.height}px`; + proxy.style.zIndex = '999999'; + proxy.style.pointerEvents = 'none'; + proxy.style.margin = '0'; + proxy.style.boxSizing = 'border-box'; + proxy.style.transform = 'none'; + document.body.appendChild(proxy); + return proxy; +} + +export function defaultDragProxyMove({ + proxyElement, + dx, + dy, +}: DragProxyMoveParams) { + proxyElement.style.transform = `translate3d(${dx}px, ${dy}px, 0)`; +} + +type ProxyState = { + proxyElement: HTMLElement; + originalNode: HTMLElement; + dragItemId: string; + initialRect: DOMRect; + cleanup?: (params: { proxyElement: HTMLElement }) => void; +}; + +function cleanupProxy(proxyStateRef: React.RefObject) { + const state = proxyStateRef.current; + if (!state) return; + + if (state.cleanup) { + state.cleanup({ proxyElement: state.proxyElement }); + } else { + state.proxyElement.remove(); + } + state.originalNode.style.visibility = ''; + proxyStateRef.current = null; +} + export const DragList = (props: DragListProps) => { const domRef = React.useRef(null); + const proxyStateRef = React.useRef(null); + + const [dragProxyRenderState, setDragProxyRenderState] = useState | null>(null); + const sourceScrollOffsetRef = React.useRef({ top: 0, left: 0 }); + const proxyPortalNodeRef = React.useRef(null); + const proxyRefCallback = React.useCallback((node: HTMLElement | null) => { + proxyPortalNodeRef.current = node; + }, []); const { dropTargetListId, dragSourceListId } = useDragDropProvider(); @@ -271,7 +444,7 @@ export const DragList = (props: DragListProps) => { const removeOnDragMove = interactionTarget.on( 'move', - ({ offsetsForItems, items, status }) => { + ({ offsetsForItems, items, status, dragItem }) => { if (status === 'rejected') { return; } @@ -279,14 +452,28 @@ export const DragList = (props: DragListProps) => { const dragItems = getDragItems(); const updatePosition = getUpdatePosition(); + const proxyState = proxyStateRef.current; items.forEach((item, index) => { - const offset = offsetsForItems[index]; + let offset = offsetsForItems[index]; const node = dragItems![index] as HTMLElement; if (!node) { return; } + + if (proxyState && item.id === proxyState.dragItemId) { + return; + } + + if (!proxyState && item.id === dragItem.id) { + const scrollOffset = sourceScrollOffsetRef.current; + offset = { + left: offset.left + scrollOffset.left, + top: offset.top + scrollOffset.top, + }; + } + updatePosition({ id: item.id, node, @@ -331,6 +518,14 @@ export const DragList = (props: DragListProps) => { }); }); + cleanupProxy(proxyStateRef); + + setDragProxyRenderState((prev) => { + if (!prev) return prev; + requestAnimationFrame(() => setDragProxyRenderState(null)); + return { ...prev, dragItemId: null }; + }); + const accepted = status === 'accepted'; if ( @@ -379,10 +574,74 @@ export const DragList = (props: DragListProps) => { } }); + // For target lists (non-initial), set up auto-scrolling so that + // dragging near the target's scroll container edge scrolls it. + // Source list auto-scroll is handled separately in onDragItemPointerDown. + let targetAutoScroller: AutoScroller | null = null; + let targetScrollContainer: HTMLElement | null = null; + let targetScrollHandler: (() => void) | null = null; + let targetPointermoveHandler: ((e: PointerEvent) => void) | null = null; + + if (!interactionTarget.getData().initial && domRef.current) { + targetAutoScroller = new AutoScroller({ + orientation: props.orientation, + onScroll: () => {}, + }); + targetAutoScroller.start(domRef.current); + targetScrollContainer = targetAutoScroller.getScrollContainer(); + + if (targetScrollContainer) { + let lastScroll = + props.orientation === 'vertical' + ? targetScrollContainer.scrollTop + : targetScrollContainer.scrollLeft; + + targetScrollHandler = () => { + const current = + props.orientation === 'vertical' + ? targetScrollContainer!.scrollTop + : targetScrollContainer!.scrollLeft; + const delta = current - lastScroll; + lastScroll = current; + + if (delta !== 0) { + interactionTarget.adjustForScroll(delta); + DragManager.retriggerMove(); + } + }; + + targetScrollContainer.addEventListener('scroll', targetScrollHandler); + } + + targetPointermoveHandler = (e: PointerEvent) => { + targetAutoScroller!.updatePointer({ + left: e.clientX, + top: e.clientY, + }); + }; + getGlobal().addEventListener('pointermove', targetPointermoveHandler); + } + return () => { removeOnDragStart(); removeOnDragMove(); removeOnDragDrop(); + + if (targetAutoScroller) { + targetAutoScroller.stop(); + } + if (targetScrollContainer && targetScrollHandler) { + targetScrollContainer.removeEventListener( + 'scroll', + targetScrollHandler, + ); + } + if (targetPointermoveHandler) { + getGlobal().removeEventListener( + 'pointermove', + targetPointermoveHandler, + ); + } }; } @@ -442,6 +701,7 @@ export const DragList = (props: DragListProps) => { dragListId: props.dragListId, acceptDropsFrom: props.acceptDropsFrom, initial: true, + preserveDragSpace: props.preserveDragSpace, }); const draggableItems = interactionTarget.getData().draggableItems; @@ -451,6 +711,15 @@ export const DragList = (props: DragListProps) => { ); const dragItem = draggableItems[dragIndex]; + const dragItemNode = + dragItems[dragIndex].getAttribute(DRAG_ITEM_ATTRIBUTE) === + `${dragItemId}` + ? dragItems[dragIndex] + : dragItems.find( + (node) => + node.getAttribute(DRAG_ITEM_ATTRIBUTE) === `${dragItemId}`, + ); + setInteractionTarget(interactionTarget); // we don't want to create the drag operation here @@ -475,7 +744,65 @@ export const DragList = (props: DragListProps) => { top: e.clientY, }; + let lastPointer = { left: e.clientX, top: e.clientY }; + sourceScrollOffsetRef.current = { top: 0, left: 0 }; + + const autoScroller = new AutoScroller({ + orientation: props.orientation, + onScroll: () => {}, + }); + + if (domRef.current) { + autoScroller.start(domRef.current); + } + + const scrollContainer = autoScroller.getScrollContainer(); + + let lastSourceScroll = scrollContainer + ? props.orientation === 'vertical' + ? scrollContainer.scrollTop + : scrollContainer.scrollLeft + : 0; + + function fireDragMove() { + if (!dragOperation) return; + dragOperation.move({ + left: lastPointer.left, + top: lastPointer.top, + }); + } + + const onContainerScroll = () => { + if (!scrollContainer) return; + const current = + props.orientation === 'vertical' + ? scrollContainer.scrollTop + : scrollContainer.scrollLeft; + const delta = current - lastSourceScroll; + lastSourceScroll = current; + + if (delta !== 0) { + interactionTarget.adjustForScroll(delta); + if (props.orientation === 'vertical') { + sourceScrollOffsetRef.current.top += delta; + } else { + sourceScrollOffsetRef.current.left += delta; + } + } + + fireDragMove(); + }; + scrollContainer?.addEventListener('scroll', onContainerScroll); + + const dragStrategy = props.dragStrategy ?? 'proxy'; + const useProxy = dragStrategy === 'proxy' && !props.renderDragProxy; + const useRenderProxy = + dragStrategy === 'proxy' && !!props.renderDragProxy; + const proxyMove = props.onDragProxyMove ?? defaultDragProxyMove; + const onPointerMove = (e: PointerEvent) => { + lastPointer = { left: e.clientX, top: e.clientY }; + if (!dragOperation) { dragOperation = DragManager.startDrag( { @@ -485,21 +812,98 @@ export const DragList = (props: DragListProps) => { }, initialCoords, ); - // return; + + if (dragItemNode && useProxy) { + const rect = dragItemNode.getBoundingClientRect(); + + let cachedProxy: HTMLElement | null = null; + const setupParams: DragProxySetupParams = { + dragItemNode, + dragItemId: `${dragItemId}`, + initialRect: rect, + initialCoords, + get proxyElement() { + if (!cachedProxy) { + cachedProxy = createDefaultProxy(dragItemNode, rect); + } + return cachedProxy; + }, + }; + + const setupResult = props.onDragProxySetup?.(setupParams); + const proxyElement = + setupResult?.proxyElement ?? setupParams.proxyElement; + + dragItemNode.style.visibility = 'hidden'; + proxyStateRef.current = { + proxyElement, + originalNode: dragItemNode, + dragItemId: `${dragItemId}`, + initialRect: rect, + cleanup: setupResult?.cleanup, + }; + } + + if (dragItemNode && useRenderProxy) { + const rect = dragItemNode.getBoundingClientRect(); + dragItemNode.style.visibility = 'hidden'; + + proxyStateRef.current = { + proxyElement: dragItemNode, + originalNode: dragItemNode, + dragItemId: `${dragItemId}`, + initialRect: rect, + // Portal handles its own DOM; only restore visibility. + cleanup: () => {}, + }; + + setDragProxyRenderState({ + dragItemId: `${dragItemId}`, + initialRect: rect, + dx: 0, + dy: 0, + }); + } + } + + const dx = e.clientX - initialCoords.left; + const dy = e.clientY - initialCoords.top; + + if (useRenderProxy) { + setDragProxyRenderState((prev) => + prev ? { ...prev, dx, dy } : prev, + ); + } else { + const proxyState = proxyStateRef.current; + if (proxyState) { + proxyMove({ proxyElement: proxyState.proxyElement, dx, dy }); + } } - dragOperation!.move({ + autoScroller.updatePointer({ left: e.clientX, top: e.clientY, }); + + fireDragMove(); }; const onPointerUp = () => { + autoScroller.stop(); + scrollContainer?.removeEventListener('scroll', onContainerScroll); getGlobal().removeEventListener('pointermove', onPointerMove); + + if (useRenderProxy) { + setDragProxyRenderState((prev) => + prev ? { ...prev, dragItemId: null } : prev, + ); + requestAnimationFrame(() => { + setDragProxyRenderState(null); + }); + } + if (!dragOperation) { - // we didn't have an pointer move - // so we need to discard the interaction target - // as we won't have a drop event triggered + cleanupProxy(proxyStateRef); setInteractionTarget(null); return; } @@ -516,6 +920,10 @@ export const DragList = (props: DragListProps) => { props.dragListId, props.acceptDropsFrom, props.removeOnDropOutside, + props.dragStrategy, + props.renderDragProxy, + props.onDragProxySetup, + props.onDragProxyMove, ], ); @@ -562,6 +970,12 @@ export const DragList = (props: DragListProps) => { return ( {children} + {dragProxyRenderState && props.renderDragProxy + ? props.renderDragProxy({ + ...dragProxyRenderState, + ref: proxyRefCallback, + }) + : null} ); }; diff --git a/source/src/components/InfiniteTable/components/draggable/DragManager.ts b/source/src/components/InfiniteTable/components/draggable/DragManager.ts index 9b6a8aa5..ce723cd6 100644 --- a/source/src/components/InfiniteTable/components/draggable/DragManager.ts +++ b/source/src/components/InfiniteTable/components/draggable/DragManager.ts @@ -49,6 +49,8 @@ export class DragManager { public static dragInteractionTargetsMap: Map = new Map(); + private static currentDragOperation: DragOperation | null = null; + public static startDrag( dragSource: ActiveDragSource | ActiveDragSourceData, startPoint: PointCoords, @@ -62,6 +64,8 @@ export class DragManager { startPoint, }); + DragManager.currentDragOperation = dragOperation; + dragOperation.on('start', DragManager.onDragStart); dragOperation.on('move', DragManager.onDragMove); dragOperation.on('drop', DragManager.onDragDrop); @@ -71,6 +75,18 @@ export class DragManager { return dragOperation; } + /** + * Re-evaluates the current drag position against all interaction targets. + * Used when a target list scrolls and its breakpoints change, requiring + * the drop index to be recalculated without a new pointer move. + */ + public static retriggerMove() { + const op = DragManager.currentDragOperation; + if (op && op.phase === 'move') { + DragManager.onDragMove(op); + } + } + private static emitter = new EventEmitter(); private static emit( event: E, @@ -304,6 +320,7 @@ export class DragManager { dropIndex: event?.dropIndex ?? -1, }); + DragManager.currentDragOperation = null; DragManager.unregisterAll(); } } diff --git a/source/src/components/InfiniteTable/components/draggable/dragOperationHandlers.ts b/source/src/components/InfiniteTable/components/draggable/dragOperationHandlers.ts index 5c6ac868..4a6a70b3 100644 --- a/source/src/components/InfiniteTable/components/draggable/dragOperationHandlers.ts +++ b/source/src/components/InfiniteTable/components/draggable/dragOperationHandlers.ts @@ -192,7 +192,10 @@ export const handleOutsideOperation = ( return { left: 0, top: 0 }; } - if (index > dragIndex) { + if ( + index > dragIndex && + !interactionTarget.getData().preserveDragSpace + ) { return orientation === 'horizontal' ? { left: -dragItem.rect.width, top: 0 } : { left: 0, top: -dragItem.rect.height }; diff --git a/source/src/components/InfiniteTable/components/draggable/index.tsx b/source/src/components/InfiniteTable/components/draggable/index.tsx index 96c5a3d2..e1774810 100644 --- a/source/src/components/InfiniteTable/components/draggable/index.tsx +++ b/source/src/components/InfiniteTable/components/draggable/index.tsx @@ -8,7 +8,14 @@ export { DragList, useDragListContext, DRAG_ITEM_ATTRIBUTE, + createDefaultProxy, + defaultDragProxyMove, type DragListProps, + type DragProxySetupParams, + type DragProxySetupResult, + type DragProxyMoveParams, + type DragProxyRenderParams, } from './DragList'; export { DragInteractionTarget } from './DragInteractionTarget'; +export { AutoScroller, type AutoScrollerConfig } from './AutoScroller'; diff --git a/source/src/components/InfiniteTable/hooks/useColumnPointerEvents.ts b/source/src/components/InfiniteTable/hooks/useColumnPointerEvents.ts index 15b67fc8..6c708b43 100644 --- a/source/src/components/InfiniteTable/hooks/useColumnPointerEvents.ts +++ b/source/src/components/InfiniteTable/hooks/useColumnPointerEvents.ts @@ -1,5 +1,6 @@ import * as React from 'react'; import { useCallback, useState } from 'react'; +import { flushSync } from 'react-dom'; import { useInfiniteTableSelector } from './useInfiniteTableSelector'; import { @@ -196,14 +197,6 @@ export const useColumnPointerEvents = ({ const currentComputedColumnOrder = getComputed().computedColumnOrder; if (JSON.stringify(columnOrder, currentComputedColumnOrder)) { - computedVisibleColumns.forEach((col) => { - setInfiniteColumnOffsetWhileReordering( - col.computedVisibleIndex, - '', - rootRef.current, - ); - }); - // componentActions.columnOrder = columnOrder; // we can't simply do the above line // as it would discard non visible columns from the column order @@ -283,7 +276,8 @@ export const useColumnPointerEvents = ({ requestAnimationFrame(() => { const { multiSortBehavior } = getState(); const target = domRef.current!; - rootRef.current?.classList.remove(InfiniteClsShiftingColumns); + const rootNode = rootRef.current; + rootNode?.classList.remove(InfiniteClsShiftingColumns); dragger.stop(); @@ -293,34 +287,6 @@ export const useColumnPointerEvents = ({ discardAlwaysRenderedColumn?.(); restoreRenderRange?.(); - computedVisibleColumns.forEach((col) => { - clearInfiniteColumnReorderDuration( - col.computedVisibleIndex, - rootRef.current, - ); - setInfiniteColumnVisibility( - col.computedVisibleIndex, - '', - rootRef.current, - ); - setInfiniteColumnOffsetWhileReordering( - col.computedVisibleIndex, - '', - rootRef.current, - ); - }); - - // setInfiniteColumnVisibility(dragColumnIndex, '', rootRef.current); - - setInfiniteColumnZIndex( - dragColumnIndex, - getColumnZIndex(dragColumn, { - pinnedStartColsCount: computedPinnedStartColumns.length, - visibleColsCount: computedVisibleColumns.length, - }), - rootRef.current, - ); - target.style.cursor = initialCursor as string; target.releasePointerCapture(pointerId); @@ -341,20 +307,44 @@ export const useColumnPointerEvents = ({ }); } - if (reorderDragResult) { - persistColumnOrder(reorderDragResult); - } else if (didDragAtLeastOnce) { - if (allowColumnHideOnDrag) { - // the column was dropped outside - // so we need to hide it - api.setVisibilityForColumn(dragColumn.id, false); + // Make sure state commit happens before we clear drag-only CSS vars. + flushSync(() => { + if (reorderDragResult) { + persistColumnOrder(reorderDragResult); + } else if (didDragAtLeastOnce) { + if (allowColumnHideOnDrag) { + // the column was dropped outside + // so we need to hide it + api.setVisibilityForColumn(dragColumn.id, false); + } } - } + actions.columnReorderDragColumnId = false; + if (horizontalLayoutPageIndex != null) { + actions.columnReorderInPageIndex = null; + } + }); - actions.columnReorderDragColumnId = false; - if (horizontalLayoutPageIndex != null) { - actions.columnReorderInPageIndex = null; - } + computedVisibleColumns.forEach((col) => { + clearInfiniteColumnReorderDuration( + col.computedVisibleIndex, + rootNode, + ); + setInfiniteColumnVisibility(col.computedVisibleIndex, '', rootNode); + setInfiniteColumnOffsetWhileReordering( + col.computedVisibleIndex, + '', + rootNode, + ); + }); + + setInfiniteColumnZIndex( + dragColumnIndex, + getColumnZIndex(dragColumn, { + pinnedStartColsCount: computedPinnedStartColumns.length, + visibleColsCount: computedVisibleColumns.length, + }), + rootNode, + ); }); }; diff --git a/www/content/docs/releases/index.page.md b/www/content/docs/releases/index.page.md index 3f1292c6..0aba0e68 100644 --- a/www/content/docs/releases/index.page.md +++ b/www/content/docs/releases/index.page.md @@ -3,6 +3,10 @@ title: Releases description: All releases | Infinite Table DataGrid for React --- +## 8.0.2 — 03.04.2026 + +Improvements to the drag and drop implementation. + ## 8.0.0 Perf improvements by refactoring the usage of React context.