Skip to content

Commit 98c7193

Browse files
authored
fix(RAC): ListBox DnD with actions (#9150)
* fix: listbox dnd with actions * add story
1 parent a8ccac8 commit 98c7193

File tree

3 files changed

+141
-1
lines changed

3 files changed

+141
-1
lines changed

packages/react-aria-components/src/ListBox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function
381381

382382
let draggableItem: DraggableItemResult | null = null;
383383
if (dragState && dragAndDropHooks) {
384-
draggableItem = dragAndDropHooks.useDraggableItem!({key: item.key}, dragState);
384+
draggableItem = dragAndDropHooks.useDraggableItem!({key: item.key, hasAction: states.hasAction}, dragState);
385385
}
386386

387387
let droppableItem: DroppableItemResult | null = null;

packages/react-aria-components/stories/ListBox.stories.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,3 +748,63 @@ AsyncListBoxVirtualized.story = {
748748
delay: 50
749749
}
750750
};
751+
752+
export let VirtualizedListBoxDndOnAction: ListBoxStory = () => {
753+
let items: {id: number, name: string}[] = [];
754+
for (let i = 0; i < 100; i++) {
755+
items.push({id: i, name: `Item ${i}`});
756+
}
757+
758+
let list = useListData({
759+
initialItems: items
760+
});
761+
762+
let {dragAndDropHooks} = useDragAndDrop({
763+
getItems: (keys) => {
764+
return [...keys].map(key => ({'text/plain': list.getItem(key)?.name ?? ''}));
765+
},
766+
onReorder(e) {
767+
if (e.target.dropPosition === 'before') {
768+
list.moveBefore(e.target.key, e.keys);
769+
} else if (e.target.dropPosition === 'after') {
770+
list.moveAfter(e.target.key, e.keys);
771+
}
772+
},
773+
renderDropIndicator(target) {
774+
return <DropIndicator target={target} style={({isDropTarget}) => ({width: '100%', height: 2, background: isDropTarget ? 'blue' : 'gray', margin: '2px 0'})} />;
775+
}
776+
});
777+
778+
return (
779+
<div style={{display: 'flex', flexDirection: 'column', gap: 20, alignItems: 'center'}}>
780+
<div style={{padding: 20, background: '#f0f0f0', borderRadius: 8, maxWidth: 600}}>
781+
<h3 style={{margin: '0 0 10px 0'}}>Instructions:</h3>
782+
<ul style={{margin: 0, paddingLeft: 20}}>
783+
<li><strong>Enter:</strong> Triggers onAction</li>
784+
<li><strong>Alt+Enter:</strong> Starts drag mode</li>
785+
<li><strong>Space:</strong> Toggles selection</li>
786+
</ul>
787+
</div>
788+
<div style={{height: 400, width: 300, resize: 'both', padding: 20, overflow: 'hidden', border: '2px solid #ccc', borderRadius: 8}}>
789+
<Virtualizer
790+
layout={ListLayout}
791+
layoutOptions={{
792+
rowHeight: 25,
793+
gap: 4
794+
}}>
795+
<ListBox
796+
className={styles.menu}
797+
selectionMode="multiple"
798+
style={{width: '100%', height: '100%'}}
799+
aria-label="Virtualized listbox with drag and drop and onAction"
800+
items={list.items}
801+
dragAndDropHooks={dragAndDropHooks}
802+
onAction={action('onAction')}>
803+
{item => <MyListBoxItem>{item.name}</MyListBoxItem>}
804+
</ListBox>
805+
</Virtualizer>
806+
</div>
807+
</div>
808+
);
809+
};
810+

packages/react-aria-components/test/ListBox.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,6 +1242,86 @@ describe('ListBox', () => {
12421242
keyPress('Escape');
12431243
act(() => jest.runAllTimers());
12441244
});
1245+
1246+
it('should support onAction with drag and drop in virtualized list', async () => {
1247+
let items = [];
1248+
for (let i = 0; i < 20; i++) {
1249+
items.push({id: i, name: 'Item ' + i});
1250+
}
1251+
1252+
jest.restoreAllMocks();
1253+
jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100);
1254+
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100);
1255+
1256+
let onAction = jest.fn();
1257+
let onReorder = jest.fn();
1258+
1259+
function VirtualizedDraggableListBox() {
1260+
let {dragAndDropHooks} = useDragAndDrop({
1261+
getItems: (keys) => [...keys].map((key) => ({'text/plain': key})),
1262+
onReorder,
1263+
renderDropIndicator: (target) => <DropIndicator target={target}>Drop</DropIndicator>
1264+
});
1265+
1266+
return (
1267+
<Virtualizer layout={ListLayout} layoutOptions={{rowHeight: 25}}>
1268+
<ListBox
1269+
aria-label="Test"
1270+
dragAndDropHooks={dragAndDropHooks}
1271+
onAction={onAction}
1272+
items={items}>
1273+
{item => <ListBoxItem>{item.name}</ListBoxItem>}
1274+
</ListBox>
1275+
</Virtualizer>
1276+
);
1277+
}
1278+
1279+
let {getAllByRole} = render(<VirtualizedDraggableListBox />);
1280+
let options = getAllByRole('option');
1281+
1282+
// Focus first item
1283+
await user.tab();
1284+
expect(document.activeElement).toBe(options[0]);
1285+
1286+
// Pressing Enter should trigger onAction, and not start drag
1287+
keyPress('Enter');
1288+
act(() => jest.runAllTimers());
1289+
expect(onAction).toHaveBeenCalledTimes(1);
1290+
expect(onAction).toHaveBeenCalledWith(0);
1291+
expect(onReorder).not.toHaveBeenCalled();
1292+
1293+
// Should not be in drag mode
1294+
options = getAllByRole('option');
1295+
expect(options.filter(opt => opt.classList.contains('react-aria-DropIndicator'))).toHaveLength(0);
1296+
1297+
// Now test that Alt+Enter starts drag mode
1298+
expect(document.activeElement).toBe(options[0]);
1299+
fireEvent.keyDown(document.activeElement, {key: 'Enter', altKey: true});
1300+
fireEvent.keyUp(document.activeElement, {key: 'Enter', altKey: true});
1301+
act(() => jest.runAllTimers());
1302+
1303+
// Verify we're in drag mode
1304+
options = getAllByRole('option');
1305+
let dropIndicators = options.filter(opt => opt.classList.contains('react-aria-DropIndicator'));
1306+
expect(dropIndicators.length).toBeGreaterThan(0);
1307+
expect(document.activeElement).toHaveAttribute('aria-label');
1308+
expect(document.activeElement.getAttribute('aria-label')).toContain('Insert');
1309+
1310+
// onAction should not have been called again
1311+
expect(onAction).toHaveBeenCalledTimes(1);
1312+
1313+
// Complete the drop
1314+
keyPress('ArrowDown');
1315+
expect(document.activeElement.getAttribute('aria-label')).toContain('Insert');
1316+
keyPress('Enter');
1317+
act(() => jest.runAllTimers());
1318+
1319+
expect(onReorder).toHaveBeenCalledTimes(1);
1320+
1321+
// Verify we're no longer in drag mode
1322+
options = getAllByRole('option');
1323+
expect(options.filter(opt => opt.classList.contains('react-aria-DropIndicator'))).toHaveLength(0);
1324+
});
12451325
});
12461326

12471327
describe('inside modals', () => {

0 commit comments

Comments
 (0)