From a9948e24ff1df10f517805904ba91e21eacb4c4c Mon Sep 17 00:00:00 2001 From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Sun, 25 Jan 2026 14:15:21 +0000 Subject: [PATCH 1/2] fix: Fix crash when dragging into an empty ListBox with ListBoxLoadMoreItem --- .../dnd/src/ListDropTargetDelegate.ts | 5 ++ .../stories/ListBox.stories.tsx | 86 ++++++++++++++++++- .../react-aria-components/stories/styles.css | 6 ++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts b/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts index 54227fc6f3e..45e3495ed47 100644 --- a/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts +++ b/packages/@react-aria/dnd/src/ListDropTargetDelegate.ts @@ -101,6 +101,11 @@ export class ListDropTargetDelegate implements DropTargetDelegate { // Can see https://github.com/adobe/react-spectrum/pull/4210/files#diff-21e555e0c597a28215e36137f5be076a65a1e1456c92cd0fdd60f866929aae2a for additional logic // that may need to happen then let items = [...this.collection].filter(item => item.type === 'item'); + + if (items.length < 1) { + return {type: 'root'}; + } + let low = 0; let high = items.length; while (low < high) { diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 0256660a9ee..0eeeebd2f7c 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Collection, DropIndicator, GridLayout, Header, ListBox, ListBoxItem, ListBoxProps, ListBoxSection, ListLayout, Separator, Text, useDragAndDrop, Virtualizer, WaterfallLayout} from 'react-aria-components'; +import {Collection, DragAndDropHooks, DropIndicator, GridLayout, Header, isTextDropItem, ListBox, ListBoxItem, ListBoxProps, ListBoxSection, ListLayout, Separator, Text, useDragAndDrop, Virtualizer, WaterfallLayout} from 'react-aria-components'; import {ListBoxLoadMoreItem} from '../'; import {LoadingSpinner, MyListBoxItem} from './utils'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; @@ -808,3 +808,87 @@ export let VirtualizedListBoxDndOnAction: ListBoxStory = () => { ); }; +interface AlbumListBoxProps { + items?: Album[], + dragAndDropHooks?: DragAndDropHooks +} + +function AlbumListBox(props: AlbumListBoxProps) { + const {dragAndDropHooks, items} = props; + + return ( + 'Drop items here'} + selectionMode="multiple"> + + {(item) => ( + + + {item.title} + {item.artist} + + )} + + + + ); +} + +function DraggableListBox() { + const list = useListData({ + initialItems: albums + }); + + const {dragAndDropHooks} = useDragAndDrop({ + getItems(keys, items) { + return items.map((item) => { + return { + album: JSON.stringify(item) + }; + }); + }, + onDragEnd(e) { + const {dropOperation, isInternal, keys} = e; + if (dropOperation === 'move' && !isInternal) { + list.remove(...keys); + } + } + }); + + return ; +} + +function DroppableListBox() { + const list = useListData({}); + + const {dragAndDropHooks} = useDragAndDrop({ + acceptedDragTypes: ['album'], + async onRootDrop(e) { + const items = await Promise.all( + e.items + .filter(isTextDropItem) + .map(async (item) => JSON.parse(await item.getText('album'))) + ); + list.append(...items); + } + }); + + return ; +} + +export const DropOntoRoot = () => ( +
+ + +
+); diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 0204f31e265..6d1079ef57c 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -79,6 +79,12 @@ flex-direction: row; } + &[data-drop-target] { + outline: 2px solid purple; + outline-offset: -2px; + background: rgb(from purple r g b / 20%); + } + :global(.react-aria-MenuItem), :global(.react-aria-ListBoxItem) { position: relative; From 3ac5b22fa397ec6cb3b0963c72872d44a07d2dbf Mon Sep 17 00:00:00 2001 From: Reid Barber Date: Mon, 26 Jan 2026 11:19:06 -0600 Subject: [PATCH 2/2] add unit test --- .../test/ListBox.test.js | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index a46b9fc71aa..6f7f876a9f0 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -28,6 +28,7 @@ import { useDragAndDrop, Virtualizer } from '../'; +import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks'; import {ListBoxLoadMoreItem} from '../src/ListBox'; import React, {useEffect, useState} from 'react'; import {User} from '@react-aria/test-utils'; @@ -1167,6 +1168,64 @@ describe('ListBox', () => { expect(onRootDrop).toHaveBeenCalledTimes(1); }); + it('should support dropping into an empty ListBox with a ListBoxLoadMoreItem', () => { + let onRootDrop = jest.fn(); + let onLoadMore = jest.fn(); + + let EmptyListBoxWithLoader = (props) => { + let {dragAndDropHooks} = useDragAndDrop({ + getItems: (keys) => [...keys].map((key) => ({'text/plain': key})), + ...props + }); + + return ( + + + {(item) => {item.name}} + + + + ); + }; + + let {getAllByRole} = render(<> + + + ); + + // Mock getBoundingClientRect for getDropTargetFromPoint + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + if (this.getAttribute('role') === 'listbox') { + return {top: 0, left: 0, bottom: 100, right: 100, width: 100, height: 100}; + } + // Item in first listbox + if (this.getAttribute('data-key') === 'cat') { + return {top: 0, left: 0, bottom: 30, right: 100, width: 100, height: 30}; + } + return {top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0}; + }); + + let listboxes = getAllByRole('listbox'); + let options = getAllByRole('option'); + + // Start dragging from first listbox + let dataTransfer = new DataTransfer(); + fireEvent(options[0], new DragEvent('dragstart', {dataTransfer, clientX: 5, clientY: 5})); + act(() => jest.runAllTimers()); + + // Drag over the empty listbox (which only has a loader) + fireEvent(listboxes[1], new DragEvent('dragenter', {dataTransfer, clientX: 50, clientY: 50})); + fireEvent(listboxes[1], new DragEvent('dragover', {dataTransfer, clientX: 50, clientY: 50})); + + expect(listboxes[1]).toHaveAttribute('data-drop-target', 'true'); + + // Drop on the empty listbox + fireEvent(listboxes[1], new DragEvent('drop', {dataTransfer, clientX: 50, clientY: 50})); + act(() => jest.runAllTimers()); + + expect(onRootDrop).toHaveBeenCalledTimes(1); + }); + it('should support horizontal orientation', async () => { let onReorder = jest.fn(); let {getAllByRole} = render();