Back to React Spectrum

useDraggableCollection

packages/react-aria/docs/dnd/useDraggableCollection.mdx

2022-12-1622.0 KB
Original Source

{/* Copyright 2020 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */}

import {Layout} from '@react-spectrum/docs'; export default Layout;

import docs from 'docs:@react-aria/dnd'; import statelyDocs from 'docs:@react-stately/dnd'; import sharedDocs from 'docs:@react-types/shared'; import selectionDocs from 'docs:@react-aria/selection'; import {HeaderInfo, FunctionAPI, TypeContext, InterfaceType, TypeLink, PageDescription} from '@react-spectrum/docs'; import packageData from '@react-aria/dnd/package.json'; import ChevronRight from '@spectrum-icons/workflow/ChevronRight'; import {Keyboard} from '@react-spectrum/text';


category: Drag and Drop keywords: [drag, drop, dnd, drag and drop, aria, accessibility] type: interaction

useDraggableCollection

<PageDescription>{docs.exports.useDraggableCollection.description}</PageDescription>

<HeaderInfo packageData={packageData} componentNames={['useDraggableCollection', 'useDraggableItem']} />

API

<FunctionAPI function={statelyDocs.exports.useDraggableCollectionState} links={statelyDocs.links} /> <FunctionAPI function={docs.exports.useDraggableCollection} links={docs.links} /> <FunctionAPI function={docs.exports.useDraggableItem} links={docs.links} />

Introduction

Collection components built with hooks such as useListBox, useTable, and useGridList can support drag and drop interactions. Users can drag multiple selected items at once, or drag individual non-selected items.

React Aria supports traditional mouse and touch based drag and drop, but also implements keyboard and screen reader friendly interactions. Users can press <Keyboard>Enter</Keyboard> on a draggable element to enter drag and drop mode. Then, they can press <Keyboard>Tab</Keyboard> to navigate between drop targets, and <Keyboard>Enter</Keyboard> to drop or <Keyboard>Escape</Keyboard> to cancel. Touch screen reader users can also drag by double tapping to activate drag and drop mode, swiping between drop targets, and double tapping again to drop.

See the drag and drop introduction to learn more.

Implementation

The <TypeLink links={docs.links} type={docs.exports.useDraggableCollection} /> hook implements drag interactions within any collection component, using state managed by <TypeLink links={statelyDocs.links} type={statelyDocs.exports.useDraggableCollectionState} />. The <TypeLink links={docs.links} type={docs.exports.useDraggableItem} /> hook should be added to each individual item within the collection to make it draggable, combining props from the relevant hook (e.g. useOption). These hooks integrate with React Aria's selection system to enable dragging multiple selected items at once.

Example

This example renders a ListBox using the useListBox hook, and adds support for dragging items. The highlighted code sections below show the main additions for drag and drop compared with a normal listbox.

tsx
import {useListBox, useOption} from '@react-aria/listbox';
import {useListState} from '@react-stately/list';
import {Item} from '@react-stately/collections';
import {useFocusRing} from '@react-aria/focus';
import {mergeProps} from '@react-aria/utils';
import {useDraggableCollectionState} from '@react-stately/dnd';
import {useDraggableCollection, useDraggableItem} from '@react-aria/dnd';

function ListBox(props) {
  // Setup listbox as normal. See the useListBox docs for more details.
  let state = useListState(props);
  let ref = React.useRef(null);
  let {listBoxProps} = useListBox({
    ...props,
    ///- begin highlight -///
    // Prevent dragging from changing selection.
    shouldSelectOnPressUp: true
    ///- end highlight -///
  }, state, ref);

  ///- begin highlight -///
  // Setup drag state for the collection.
  let dragState = useDraggableCollectionState({
    // Pass through events from props.
    ...props,

    // Collection and selection manager come from list state.
    collection: state.collection,
    selectionManager: state.selectionManager,

    // Provide data for each dragged item. This function could
    // also be provided by the user of the component.
    getItems: props.getItems || ((keys) => {
      return [...keys].map(key => {
        let item = state.collection.getItem(key);

        return {
          'text/plain': item.textValue
        };
      });
    })
  });

  useDraggableCollection(props, dragState, ref);
  ///- end highlight -///

  return (
    <ul {...listBoxProps} ref={ref}>
      {[...state.collection].map((item) => (
        <Option
          key={item.key}
          item={item}
          state={state}
          dragState={dragState}
        />
      ))}
    </ul>
  );
}

function Option({ item, state, dragState }) {
  // Setup listbox option as normal. See useListBox docs for details.
  let ref = React.useRef(null);
  let {optionProps} = useOption({key: item.key}, state, ref);
  let {isFocusVisible, focusProps} = useFocusRing();

  ///- begin highlight -///
  // Register the item as a drag source.
  let {dragProps} = useDraggableItem({
    key: item.key
  }, dragState);
  ///- end highlight -///

  // Merge option props and dnd props, and render the item.
  return (
    ///- begin highlight -///
    <li
      {...mergeProps(optionProps, dragProps, focusProps)}
      ///- end highlight -///
      ref={ref}
      className={`option ${isFocusVisible ? 'focus-visible' : ''}`}>
      {item.rendered}
    </li>
  );
}

<ListBox aria-label="Categories" selectionMode="multiple">
  <Item>Animals</Item>
  <Item>People</Item>
  <Item>Plants</Item>
</ListBox>
<DropTarget />
<details> <summary style={{fontWeight: 'bold'}}><ChevronRight size="S" /> Show CSS</summary>
css
[role=listbox] {
  padding: 0;
  margin: 5px 0;
  list-style: none;
  box-shadow: inset 0 0 0 1px gray;
  max-width: 250px;
  outline: none;
  min-height: 50px;
  overflow: auto;
}

.option {
  padding: 3px 6px;
  outline: none;
}

.option[aria-selected=true] {
  background: blueviolet;
  color: white;
}

.option.focus-visible {
  box-shadow: inset 0 0 0 2px orange;
}

.option.drop-target {
  border-color: transparent;
  box-shadow: inset 0 0 0 2px var(--blue);
}
</details>

DropTarget

The DropTarget component used above is defined below. See useDrop for more details and documentation.

<details> <summary style={{fontWeight: 'bold'}}><ChevronRight size="S" /> Show code</summary>
tsx
import type {TextDropItem} from '@react-aria/dnd';
import {useDrop} from '@react-aria/dnd';
import {useButton} from '@react-aria/button';

function DropTarget() {
  let [dropped, setDropped] = React.useState(null);
  let ref = React.useRef(null);
  let {dropProps, isDropTarget} = useDrop({
    ref,
    async onDrop(e) {
      let items = await Promise.all(
        e.items
          .filter(item => item.kind === 'text' && (item.types.has('text/plain') || item.types.has('my-app-custom-type')))
          .map(async (item: TextDropItem) => {
            if (item.types.has('my-app-custom-type')) {
              return JSON.parse(await item.getText('my-app-custom-type'));
            } else {
              return {name: await item.getText('text/plain'), style: 'span'};
            }
          })
      );
      setDropped(items);
    }
  });

  let {buttonProps} = useButton({elementType: 'div'}, ref);

  let message = ['Drop here'];
  if (dropped) {
    message = dropped.map((item, i) => <div key={i}><item.style>{item.name}</item.style></div>);
  }

  return (
    <div {...mergeProps(dropProps, buttonProps)} ref={ref} className={`droppable ${isDropTarget ? 'target' : ''}`}>
      {message}
    </div>
  );
}
<details> <summary style={{fontWeight: 'bold'}}><ChevronRight size="S" /> Show CSS</summary>
css
.droppable {
  width: 100px;
  height: 50px;
  border-radius: 6px;
  display: inline-block;
  padding: 20px;
  border: 2px dotted gray;
  white-space: pre-wrap;
}

.droppable.target {
  border: 2px solid var(--blue);
}
</details> </details>

Drag data

Data for a draggable element can be provided in multiple formats at once. This allows drop targets to choose data in a format that they understand. For example, you could serialize a complex object as JSON in a custom format for use within your own application, and also provide plain text and/or rich HTML fallbacks that can be used when a user drops data in an external application (e.g. an email message).

This can be done by returning multiple keys for an item from the getItems function. Types can either be a standard mime type for interoperability with external applications, or a custom string for use within your own app.

This example provides representations of each item as plain text, HTML, and a custom app-specific data format. Dropping on the drop targets in this page will use the custom data format to render formatted items. If you drop in an external application supporting rich text, the HTML representation will be used. Dropping in a text editor will use the plain text format.

tsx
let items = new Map([
  ['ps', {name: 'Photoshop', style: 'strong'}],
  ['xd', {name: 'XD', style: 'strong'}],
  ['id', {name: 'InDesign', style: 'strong'}],
  ['dw', {name: 'Dreamweaver', style: 'em'}],
  ['co', {name: 'Connect', style: 'em'}]
]);

/*- begin highlight -*/
let getItems = (keys) => (
  [...keys].map(key => {
    let item = items.get(key);
    return {
      'text/plain': item.name,
      'text/html': `<${item.style}>${item.name}</${item.style}>`,
      'my-app-custom-type': JSON.stringify(item)
    };
  })
);
/*- end highlight -*/

<ListBox aria-label="Adobe Apps" items={items} getItems={getItems} selectionMode="multiple">
  {([id, item]) => (
    <Item key={id} textValue={item.name}>
      <item.style>{item.name}</item.style>
    </Item>
  )}
</ListBox>
<DropTarget />

Drag previews

By default, the drag preview shown under the user's pointer or finger is a copy of the original element that started the drag. A custom preview can be rendered using the <DragPreview> component. This accepts a function as a child which receives the dragged data that was returned by getItems, and returns a rendered preview for those items. The DragPreview is linked with useDraggableCollectionState via a ref, passed to the preview property. The DragPreview should be placed in the component hierarchy appropriately, so that it receives any React context or inherited styles that it needs to render correctly.

This example renders a custom drag preview which shows the number of items being dragged, or the contents if there is only one.

tsx
///- begin highlight -///
import {DragPreview} from '@react-aria/dnd';
///- end highlight -///

function ListBox(props) {
  ///- begin collapse -///
  // Setup listbox as normal. See the useListBox docs for more details.
  let state = useListState(props);
  let ref = React.useRef(null);
  let {listBoxProps} = useListBox({
    ...props,
    shouldSelectOnPressUp: true
  }, state, ref);
  ///- end collapse -///
  // ...

  ///- begin highlight -///
  let preview = React.useRef(null);
  ///- end highlight -///
  let dragState = useDraggableCollectionState({
    collection: state.collection,
    selectionManager: state.selectionManager,
    ///- begin highlight -///
    preview,
    ///- end highlight -///
    getItems(keys) {
      return [...keys].map(key => {
        let item = state.collection.getItem(key);
        return {
          'text/plain': item.textValue
        };
      });
    },
  });

  useDraggableCollection(props, dragState, ref);

  return (
    <ul {...listBoxProps} ref={ref}>
      {[...state.collection].map((item) => (
        <Option
          key={item.key}
          item={item}
          state={state}
          dragState={dragState}
        />
      ))}
      <DragPreview ref={preview}>
        {items => (
          <div style={{background: 'green', color: 'white'}}>
            {items.length > 1 ? `${items.length} items` : items[0]['text/plain']}
          </div>
        )}
      </DragPreview>
    </ul>
  );
}
///- begin collapse -///
function Option({ item, state, dragState }) {
  // Setup listbox option as normal. See useListBox docs for details.
  let ref = React.useRef(null);
  let {optionProps} = useOption({key: item.key}, state, ref);
  let {isFocusVisible, focusProps} = useFocusRing();

  ///- begin highlight -///
  // Register the item as a drag source.
  let {dragProps} = useDraggableItem({
    key: item.key
  }, dragState);
  ///- end highlight -///

  // Merge option props and dnd props, and render the item.
  return (
    ///- begin highlight -///
    <li
      {...mergeProps(optionProps, dragProps, focusProps)}
      ///- end highlight -///
      ref={ref}
      className={`option ${isFocusVisible ? 'focus-visible' : ''}`}>
      {item.rendered}
    </li>
  );
}
///- end collapse -///

<ListBox aria-label="Categories" selectionMode="multiple">
  <Item>Animals</Item>
  <Item>People</Item>
  <Item>Plants</Item>
</ListBox>
<DropTarget />

Drop operations

A <TypeLink links={sharedDocs.links} type={sharedDocs.links[sharedDocs.exports.DropOperation.id]} /> is an indication of what will happen when dragged data is dropped on a particular drop target. These are:

  • move – indicates that the dragged data will be moved from its source location to the target location.
  • copy – indicates that the dragged data will be copied to the target destination.
  • link – indicates that there will be a relationship established between the source and target locations.
  • cancel – indicates that the drag and drop operation will be canceled, resulting in no changes made to the source or target.

Many operating systems display these in the form of a cursor change, e.g. a plus sign to indicate a copy operation. The user may also be able to use a modifier key to choose which drop operation to perform, such as <Keyboard>Option</Keyboard> or <Keyboard>Alt</Keyboard> to switch from move to copy.

The onDragEnd event allows the drag source to respond when a drag that it initiated ends, either because it was dropped or because it was canceled by the user. The dropOperation property of the event object indicates the operation that was performed. For example, when data is moved, the UI could be updated to reflect this change by removing the original dragged items.

This example removes the dragged items from the UI when a move operation is completed. It uses the useListData hook to help manage and update the list of items. Try holding the <Keyboard>Option</Keyboard> or <Keyboard>Alt</Keyboard> keys to change the operation to copy, and see how the behavior changes.

tsx
import {useListData} from '@react-stately/data';

function Example() {
  let list = useListData({
    initialItems: [
      {id: 'a', textValue: 'Photoshop'},
      {id: 'b', textValue: 'XD'},
      {id: 'c', textValue: 'Dreamweaver'},
      {id: 'd', textValue: 'InDesign'},
      {id: 'e', textValue: 'Connect'}
    ]
  });

  /*- begin highlight -*/
  let onDragEnd = e => {
    if (e.dropOperation === 'move') {
      list.remove(...e.keys);
    }
  };
  /*- end highlight -*/

  return <>
    <ListBox aria-label="Adobe apps" items={list.items} onDragEnd={onDragEnd} selectionMode="multiple">
      {item => <Item>{item.textValue}</Item>}
    </ListBox>
    <DropTarget />
  </>;
}

The drag source can also control which drop operations are allowed for the data. For example, if moving data is not allowed, and only copying is supported, the getAllowedDropOperations function could be implemented to indicate this. When you drag the element below, the cursor now shows the copy affordance by default, and pressing a modifier to switch drop operations results in the drop being canceled.

tsx
<ListBox
  aria-label="Categories"
  //- begin highlight -//
  getAllowedDropOperations={() => ['copy']}
  //- end highlight -//
  selectionMode="multiple"
 >
  <Item>Animals</Item>
  <Item>People</Item>
  <Item>Plants</Item>
</ListBox>
<DropTarget />

Reordering

Drag and drop can be combined in the same collection component to allow reordering items. See useDroppableCollection for more details.

tsx
import {useDroppableCollectionState} from '@react-stately/dnd';
import {useDroppableCollection, useDropIndicator, ListDropTargetDelegate} from '@react-aria/dnd';
import {ListKeyboardDelegate} from '@react-aria/selection';

function ReorderableListBox(props) {
  ///- begin collapse -///
  // See useListBox docs for more details.
  let state = useListState(props);
  let ref = React.useRef(null);
  let { listBoxProps } = useListBox({
    ...props,
    shouldSelectOnPressUp: true
  }, state, ref);

  // Setup drag state for the collection.
  let dragState = useDraggableCollectionState({
    ...props,
    // Collection and selection manager come from list state.
    collection: state.collection,
    selectionManager: state.selectionManager,
    // Provide data for each dragged item. This function could
    // also be provided by the user of the component.
    getItems: props.getItems || ((keys) => {
      return [...keys].map(key => {
        let item = state.collection.getItem(key);

        return {
          'text/plain': item.textValue
        };
      });
    }),
  });

  useDraggableCollection(props, dragState, ref);
  ///- end collapse -///
  // ...

  ///- begin highlight -///
   // Setup react-stately and react-aria hooks for dropping.
  let dropState = useDroppableCollectionState({
    ...props,
    collection: state.collection,
    selectionManager: state.selectionManager
  });

  let {collectionProps} = useDroppableCollection({
    ...props,
    // Provide drop targets for keyboard and pointer-based drag and drop.
    keyboardDelegate: new ListKeyboardDelegate(state.collection, state.disabledKeys, ref),
    dropTargetDelegate: new ListDropTargetDelegate(state.collection, ref)
  }, dropState, ref);
  ///- end highlight -///

  return (
    /*- begin highlight -*/
    <ul
      {...mergeProps(listBoxProps, collectionProps)}
      /*- end highlight -*/
      ref={ref}>
      {[...state.collection].map((item) => (
        <ReorderableOption
          key={item.key}
          item={item}
          state={state}
          dragState={dragState}
          /*- begin highlight -*/
          dropState={dropState}
          /*- end highlight -*/
        />
      ))}
    </ul>
  );
}

function ReorderableOption({ item, state, dragState, dropState }) {
  ///- begin collapse -///
  let ref = React.useRef(null);
  let { optionProps } = useOption({key: item.key}, state, ref);
  let { isFocusVisible, focusProps } = useFocusRing();

  // Register the item as a drag source.
  let {dragProps} = useDraggableItem({
    key: item.key
  }, dragState);
  ///- end collapse -///
  // ...

  return (
    <>
      <DropIndicator
        target={{type: 'item', key: item.key, dropPosition: 'before'}}
        dropState={dropState} />
      <li
        {...mergeProps(optionProps, dragProps, focusProps)}
        ref={ref}
        className={`option ${isFocusVisible ? 'focus-visible' : ''}`}>
        {item.rendered}
      </li>
      {state.collection.getKeyAfter(item.key) == null &&
        <DropIndicator
          target={{type: 'item', key: item.key, dropPosition: 'after'}}
          dropState={dropState} />
      }
    </>
  );
}

///- begin highlight -///
function DropIndicator(props) {
  let ref = React.useRef(null);
  let {dropIndicatorProps, isHidden, isDropTarget} = useDropIndicator(props, props.dropState, ref);
  if (isHidden) {
    return null;
  }

  return (
    <li
      {...dropIndicatorProps}
      role="option"
      ref={ref}
      className={`drop-indicator ${isDropTarget ? 'drop-target' : ''}`} />
  );
}
///- end highlight -///

Now, we can render an example ListBox, which allows the user to reorder items. The onReorder event is triggered when the user drops dragged items which originated within the same collection. As above, useListData is used to manage the list items in this example, but it is not a requirement.

tsx
import {useListData} from '@react-stately/data';

function Example() {
  let list = useListData({
    initialItems: [
      {id: 1, name: 'Cat'},
      {id: 2, name: 'Dog'},
      {id: 3, name: 'Kangaroo'},
      {id: 4, name: 'Panda'},
      {id: 5, name: 'Snake'}
    ]
  });

  let onReorder = e => {
    if (e.target.dropPosition === 'before') {
      list.moveBefore(e.target.key, e.keys);
    } else if (e.target.dropPosition === 'after') {
      list.moveAfter(e.target.key, e.keys);
    }
  };

  return (
    <ReorderableListBox
      aria-label="Favorite animals"
      selectionMode="multiple"
      selectionBehavior="replace"
      items={list.items}
      ///- begin highlight -///
      onReorder={onReorder}
      ///- end highlight -///
    >
      {item => <Item>{item.name}</Item>}
    </ReorderableListBox>
  );
}
<details> <summary style={{fontWeight: 'bold'}}><ChevronRight size="S" /> Show CSS</summary>
css
.drop-indicator {
  width: 100%;
  margin-left: 0;
  height: 2px;
  margin-bottom: -2px;
  outline: none;
  background: transparent;
}

.drop-indicator:last-child {
  margin-bottom: 0;
  margin-top: -2px;
}

.drop-indicator.drop-target {
  background: var(--blue);
}
</details>

Props

The full list of props supported by draggable collections is available below.

<TypeContext.Provider value={sharedDocs.links}> <InterfaceType properties={sharedDocs.exports.DraggableCollectionProps.properties} /> </TypeContext.Provider>