Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/@react-aria/dnd/src/DragManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}

Expand Down
5 changes: 5 additions & 0 deletions packages/@react-aria/dnd/src/ListDropTargetDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
86 changes: 85 additions & 1 deletion packages/react-aria-components/stories/ListBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -808,3 +808,87 @@ export let VirtualizedListBoxDndOnAction: ListBoxStory = () => {
);
};

interface AlbumListBoxProps {
items?: Album[],
dragAndDropHooks?: DragAndDropHooks<Album>
}

function AlbumListBox(props: AlbumListBoxProps) {
const {dragAndDropHooks, items} = props;

return (
<ListBox
aria-label="Albums"
dragAndDropHooks={dragAndDropHooks}
items={items}
renderEmptyState={() => 'Drop items here'}
selectionMode="multiple">
<Collection items={items}>
{(item) => (
<ListBoxItem textValue={item.title}>
<img alt="" src={item.image} />
<Text slot="label">{item.title}</Text>
<Text slot="description">{item.artist}</Text>
</ListBoxItem>
)}
</Collection>
<ListBoxLoadMoreItem />
</ListBox>
);
}

function DraggableListBox() {
const list = useListData({
initialItems: albums
});

const {dragAndDropHooks} = useDragAndDrop<Album>({
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 <AlbumListBox dragAndDropHooks={dragAndDropHooks} items={list.items} />;
}

function DroppableListBox() {
const list = useListData<Album>({});

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 <AlbumListBox dragAndDropHooks={dragAndDropHooks} items={list.items} />;
}

export const DropOntoRoot = () => (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12,
justifyContent: 'center',
width: '100%'
}}>
<DraggableListBox />
<DroppableListBox />
</div>
);
77 changes: 77 additions & 0 deletions packages/react-aria-components/stories/Table.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Table
aria-label="Table (rejects all item drops)"
selectionMode="multiple"
selectedKeys={list.selectedKeys}
onSelectionChange={list.setSelectedKeys}
dragAndDropHooks={dragAndDropHooks}>
<TableHeader>
<Column />
<Column><MyCheckbox slot="selection" /></Column>
<Column>ID</Column>
<Column isRowHeader>Name</Column>
<Column>Type</Column>
</TableHeader>
<TableBody items={list.items}>
<Collection items={list.items}>
{item => (
<Row>
<Cell><Button slot="drag">≡</Button></Cell>
<Cell><MyCheckbox slot="selection" /></Cell>
<Cell>{item.id}</Cell>
<Cell>{item.name}</Cell>
<Cell>{item.type}</Cell>
</Row>
)}
</Collection>
</TableBody>
</Table>
);
}

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 (
<Checkbox {...props}>
Expand Down
6 changes: 6 additions & 0 deletions packages/react-aria-components/stories/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
59 changes: 59 additions & 0 deletions packages/react-aria-components/test/ListBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<ListBox aria-label="Empty ListBox" dragAndDropHooks={dragAndDropHooks} {...props}>
<Collection items={[]}>
{(item) => <ListBoxItem id={item.id}>{item.name}</ListBoxItem>}
</Collection>
<ListBoxLoadMoreItem isLoading onLoadMore={onLoadMore} />
</ListBox>
);
};

let {getAllByRole} = render(<>
<DraggableListBox />
<EmptyListBoxWithLoader onRootDrop={onRootDrop} />
</>);

// 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(<DraggableListBox onReorder={onReorder} orientation="horizontal" />);
Expand Down
Loading