From 426b7400c128b3209dc199c2bb807b909fb47e55 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 6 Mar 2025 16:48:53 -0800 Subject: [PATCH] fix: Fix virtualizer drag and drop with layout gaps --- .../@react-stately/layout/src/GridLayout.ts | 42 ++++++++++++++++++- .../@react-stately/layout/src/ListLayout.ts | 21 +++++++++- .../@react-stately/layout/src/TableLayout.ts | 26 +++++++----- .../stories/ListBox.stories.tsx | 3 +- 4 files changed, 79 insertions(+), 13 deletions(-) diff --git a/packages/@react-stately/layout/src/GridLayout.ts b/packages/@react-stately/layout/src/GridLayout.ts index 0afd7efbfce..60982724915 100644 --- a/packages/@react-stately/layout/src/GridLayout.ts +++ b/packages/@react-stately/layout/src/GridLayout.ts @@ -102,6 +102,7 @@ export class GridLayout exte // Compute the number of rows and columns needed to display the content let columns = Math.floor(visibleWidth / (minItemSize.width + minSpace.width)); let numColumns = Math.max(1, Math.min(maxColumns, columns)); + this.numColumns = numColumns; // Compute the available width (minus the space between items) let width = visibleWidth - (minSpace.width * Math.max(0, numColumns)); @@ -223,7 +224,46 @@ export class GridLayout exte x += this.virtualizer!.visibleRect.x; y += this.virtualizer!.visibleRect.y; - let key = this.virtualizer!.keyAtPoint(new Point(x, y)); + // Find the closest item within on either side of the point using the gap width. + let key: Key | null = null; + if (this.numColumns === 1) { + let searchRect = new Rect(x, Math.max(0, y - this.gap.height), 1, this.gap.height * 2); + let candidates = this.getVisibleLayoutInfos(searchRect); + let minDistance = Infinity; + for (let candidate of candidates) { + // Ignore items outside the search rect, e.g. persisted keys. + if (!candidate.rect.intersects(searchRect)) { + continue; + } + + let yDist = Math.abs(candidate.rect.y - x); + let maxYDist = Math.abs(candidate.rect.maxY - x); + let dist = Math.min(yDist, maxYDist); + if (dist < minDistance) { + minDistance = dist; + key = candidate.key; + } + } + } else { + let searchRect = new Rect(Math.max(0, x - this.gap.width), y, this.gap.width * 2, 1); + let candidates = this.getVisibleLayoutInfos(searchRect); + let minDistance = Infinity; + for (let candidate of candidates) { + // Ignore items outside the search rect, e.g. persisted keys. + if (!candidate.rect.intersects(searchRect)) { + continue; + } + + let xDist = Math.abs(candidate.rect.x - x); + let maxXDist = Math.abs(candidate.rect.maxX - x); + let dist = Math.min(xDist, maxXDist); + if (dist < minDistance) { + minDistance = dist; + key = candidate.key; + } + } + } + let layoutInfo = key != null ? this.getLayoutInfo(key) : null; if (!layoutInfo) { return {type: 'root'}; diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 87198e5295d..6dea1c473b5 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -505,7 +505,26 @@ export class ListLayout exte x += this.virtualizer!.visibleRect.x; y += this.virtualizer!.visibleRect.y; - let key = this.virtualizer!.keyAtPoint(new Point(x, y)); + // Find the closest item within on either side of the point using the gap width. + let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, this.gap * 2); + let candidates = this.getVisibleLayoutInfos(searchRect); + let key: Key | null = null; + let minDistance = Infinity; + for (let candidate of candidates) { + // Ignore items outside the search rect, e.g. persisted keys. + if (!candidate.rect.intersects(searchRect)) { + continue; + } + + let yDist = Math.abs(candidate.rect.y - x); + let maxYDist = Math.abs(candidate.rect.maxY - x); + let dist = Math.min(yDist, maxYDist); + if (dist < minDistance) { + minDistance = dist; + key = candidate.key; + } + } + if (key == null || this.virtualizer!.collection.size === 0) { return {type: 'root'}; } diff --git a/packages/@react-stately/layout/src/TableLayout.ts b/packages/@react-stately/layout/src/TableLayout.ts index ec155c18205..907f10aa6d2 100644 --- a/packages/@react-stately/layout/src/TableLayout.ts +++ b/packages/@react-stately/layout/src/TableLayout.ts @@ -542,17 +542,23 @@ export class TableLayout exten x += this.virtualizer!.visibleRect.x; y += this.virtualizer!.visibleRect.y; - // Custom variation of this.virtualizer.keyAtPoint that ignores body + // Find the closest item within on either side of the point using the gap width. + let searchRect = new Rect(x, Math.max(0, y - this.gap), 1, this.gap * 2); + let candidates = this.getVisibleLayoutInfos(searchRect); let key: Key | null = null; - let point = new Point(x, y); - let rectAtPoint = new Rect(point.x, point.y, 1, 1); - let layoutInfos = this.virtualizer!.layout.getVisibleLayoutInfos(rectAtPoint).filter(info => info.type === 'row'); - - // Layout may return multiple layout infos in the case of - // persisted keys, so find the first one that actually intersects. - for (let layoutInfo of layoutInfos) { - if (layoutInfo.rect.intersects(rectAtPoint)) { - key = layoutInfo.key; + let minDistance = Infinity; + for (let candidate of candidates) { + // Ignore items outside the search rect, e.g. persisted keys. + if (candidate.type !== 'row' || !candidate.rect.intersects(searchRect)) { + continue; + } + + let yDist = Math.abs(candidate.rect.y - x); + let maxYDist = Math.abs(candidate.rect.maxY - x); + let dist = Math.min(yDist, maxYDist); + if (dist < minDistance) { + minDistance = dist; + key = candidate.key; } } diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 43748cc6e2e..b4008633204 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -317,7 +317,8 @@ export function VirtualizedListBoxDnd() {