diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index 0a4be7afba1..9895aaa818a 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -583,6 +583,9 @@ class DragSession { this.dragTarget.element.focus(); } + // Re-trigger focus event on active element, since it will not have received it during dragging (see cancelEvent). + document.activeElement?.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + announce(this.stringFormatter.format('dropCanceled')); } 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/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index f33e05c8ac2..e30c0b75c58 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -529,6 +529,83 @@ DndTableExample.args = { isLoading: false }; +function DndTableWithNoValidDropTargetsRender(): JSX.Element { + let list = useListData({ + initialItems: [ + {id: '1', type: 'file', name: 'Adobe Photoshop'}, + {id: '2', type: 'file', name: 'Adobe XD'}, + {id: '3', type: 'folder', name: 'Documents'}, + {id: '4', type: 'file', name: 'Adobe InDesign'}, + {id: '5', type: 'folder', name: 'Utilities'}, + {id: '6', type: 'file', name: 'Adobe AfterEffects'} + ] + }); + + let {dragAndDropHooks} = useDragAndDrop({ + getItems(keys) { + return [...keys].filter(k => !!list.getItem(k)).map((key) => { + let item = list.getItem(key); + return { + 'custom-app-type': JSON.stringify(item), + 'text/plain': item!.name + }; + }); + }, + onItemDrop() {}, + shouldAcceptItemDrop() { + return false; + } + }); + + return ( + + + + + ID + Name + Type + + + + {item => ( + + + + {item.id} + {item.name} + {item.type} + + )} + + +
+ ); +} + +export const DndTableWithNoValidDropTargets: TableStoryObj = { + render: DndTableWithNoValidDropTargetsRender, + name: 'Dnd Table with no valid drop targets', + parameters: { + description: { + data: `Tests that arrow keys work after canceling a keyboard drag when shouldAcceptItemDrop rejects all drop targets. + Test Instructions: + 1. Focus on an item's drag button + 2. Press Enter to start keyboard drag + 3. Notice there are no valid drop targets (shouldAcceptItemDrop rejects all item drops) + 4. Press Escape to cancel the drag + 5. Try pressing arrow keys + 6. Observe that focus moves (and we've exited virtual drag mode) + ` + } + } +}; + export const MyCheckbox = ({children, ...props}: CheckboxProps) => { return ( 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; 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();