diff --git a/packages/@react-aria/dnd/package.json b/packages/@react-aria/dnd/package.json index 826170b7f02..dc502917bf9 100644 --- a/packages/@react-aria/dnd/package.json +++ b/packages/@react-aria/dnd/package.json @@ -26,7 +26,6 @@ "@react-aria/utils": "^3.13.3", "@react-aria/visually-hidden": "^3.4.1", "@react-stately/dnd": "3.0.0-alpha.10", - "@react-stately/selection": "^3.10.3", "@react-types/button": "^3.6.1", "@react-types/shared": "^3.14.1" }, diff --git a/packages/@react-aria/dnd/src/DragManager.ts b/packages/@react-aria/dnd/src/DragManager.ts index 10913e95da7..e443d81b70f 100644 --- a/packages/@react-aria/dnd/src/DragManager.ts +++ b/packages/@react-aria/dnd/src/DragManager.ts @@ -14,7 +14,7 @@ import {announce} from '@react-aria/live-announcer'; import {ariaHideOutside} from '@react-aria/overlays'; import {DragEndEvent, DragItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropItem, DropOperation, DropTarget as DroppableCollectionTarget, FocusableElement} from '@react-types/shared'; import {flushSync} from 'react-dom'; -import {getDragModality, getTypes, setDropCollectionRef} from './utils'; +import {getDragModality, getTypes} from './utils'; import {getInteractionModality} from '@react-aria/interactions'; import type {LocalizedStringFormatter} from '@internationalized/string'; import {useEffect, useState} from 'react'; @@ -517,7 +517,6 @@ class DragSession { } cancel() { - setDropCollectionRef(undefined); this.end(); if (!this.dragTarget.element.closest('[aria-hidden="true"]')) { this.dragTarget.element.focus(); diff --git a/packages/@react-aria/dnd/src/index.ts b/packages/@react-aria/dnd/src/index.ts index 5541c50477b..09ce2dd4f2f 100644 --- a/packages/@react-aria/dnd/src/index.ts +++ b/packages/@react-aria/dnd/src/index.ts @@ -21,6 +21,7 @@ export type {DropOptions, DropResult} from './useDrop'; export type {ClipboardProps, ClipboardResult} from './useClipboard'; export type {DropTargetDelegate} from '@react-types/shared'; +export {DIRECTORY_DRAG_TYPE} from './utils'; export {useDrag} from './useDrag'; export {useDrop} from './useDrop'; export {useDroppableCollection} from './useDroppableCollection'; diff --git a/packages/@react-aria/dnd/src/useDraggableItem.ts b/packages/@react-aria/dnd/src/useDraggableItem.ts index cd2a9c48d2a..642761f3cd0 100644 --- a/packages/@react-aria/dnd/src/useDraggableItem.ts +++ b/packages/@react-aria/dnd/src/useDraggableItem.ts @@ -11,7 +11,7 @@ */ import {AriaButtonProps} from '@react-types/button'; -import {clearGlobalDnDState, globalDndState, setDraggingKeys} from './utils'; +import {clearGlobalDnDState, isInternalDropOperation, setDraggingKeys} from './utils'; import {DraggableCollectionState} from '@react-stately/dnd'; import {HTMLAttributes, Key} from 'react'; // @ts-ignore @@ -77,9 +77,9 @@ export function useDraggableItem(props: DraggableItemProps, state: DraggableColl state.moveDrag(e); }, onDragEnd(e) { - let {draggingCollectionRef, dropCollectionRef} = globalDndState; - let isInternalDrop = draggingCollectionRef?.current != null && draggingCollectionRef.current === dropCollectionRef?.current; - state.endDrag({...e, keys: state.draggingKeys, isInternalDrop}); + let {dropOperation} = e; + let isInternal = dropOperation === 'cancel' ? false : isInternalDropOperation(); + state.endDrag({...e, keys: state.draggingKeys, isInternal}); clearGlobalDnDState(); } }); diff --git a/packages/@react-aria/dnd/src/useDrop.ts b/packages/@react-aria/dnd/src/useDrop.ts index e7d08b8de93..0d84d75f138 100644 --- a/packages/@react-aria/dnd/src/useDrop.ts +++ b/packages/@react-aria/dnd/src/useDrop.ts @@ -251,7 +251,7 @@ export function useDrop(options: DropOptions): DropResult { if (dndStateSnapshot.draggingCollectionRef == null) { setGlobalDropEffect(undefined); } else { - // Otherwise we need to preserve the global dnd state for onDragEnd's isInternalDrop check. + // Otherwise we need to preserve the global dnd state for onDragEnd's isInternal check. // At the moment fireDropExit may clear dropCollectionRef (i.e. useDroppableCollection's provided onDropExit, required to clear dropCollectionRef when exiting a valid drop target) setGlobalDnDState(dndStateSnapshot); } diff --git a/packages/@react-aria/dnd/src/useDroppableCollection.ts b/packages/@react-aria/dnd/src/useDroppableCollection.ts index 2b7cac426c8..c9f4515bf10 100644 --- a/packages/@react-aria/dnd/src/useDroppableCollection.ts +++ b/packages/@react-aria/dnd/src/useDroppableCollection.ts @@ -10,11 +10,11 @@ * governing permissions and limitations under the License. */ -import {clearGlobalDnDState, globalDndState, setDropCollectionRef, useDroppableCollectionId} from './utils'; +import {clearGlobalDnDState, globalDndState, isInternalDropOperation, setDropCollectionRef, useDroppableCollectionId} from './utils'; import {Collection, DropEvent, DropOperation, DroppableCollectionDropEvent, DroppableCollectionProps, DropPosition, DropTarget, DropTargetDelegate, KeyboardDelegate, Node} from '@react-types/shared'; +import {DIRECTORY_DRAG_TYPE, getTypes} from './utils'; import * as DragManager from './DragManager'; import {DroppableCollectionState} from '@react-stately/dnd'; -import {getTypes} from './utils'; import {HTMLAttributes, Key, RefObject, useCallback, useEffect, useRef} from 'react'; import {mergeProps, useLayoutEffect} from '@react-aria/utils'; import {setInteractionModality} from '@react-aria/interactions'; @@ -55,12 +55,12 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: onRootDrop, onItemDrop, onReorder, - acceptedDragTypes, + acceptedDragTypes = 'all', shouldAcceptItemDrop } = localState.props; - let {draggingCollectionRef, draggingKeys} = globalDndState; - let isInternalDrop = draggingCollectionRef?.current != null && draggingCollectionRef?.current === ref?.current; + let {draggingKeys} = globalDndState; + let isInternal = isInternalDropOperation(ref); let { target, dropOperation, @@ -68,11 +68,11 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: } = e; let filteredItems = items; - if (acceptedDragTypes) { + if (acceptedDragTypes !== 'all' || shouldAcceptItemDrop) { filteredItems = items.filter(item => { - let itemTypes: Set; + let itemTypes: Set; if (item.kind === 'directory') { - itemTypes = new Set(['directory']); + itemTypes = new Set([DIRECTORY_DRAG_TYPE]); } else { itemTypes = item.kind === 'file' ? new Set([item.type]) : item.types; } @@ -90,22 +90,24 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: }); } - if (target.type === 'root' && onRootDrop) { - await onRootDrop({items: filteredItems, dropOperation}); - } - - if (target.type === 'item') { - if (target.dropPosition === 'on' && onItemDrop) { - await onItemDrop({items: filteredItems, dropOperation, isInternalDrop, target: {key: target.key, dropPosition: 'on'}}); + if (filteredItems.length > 0) { + if (target.type === 'root' && onRootDrop) { + await onRootDrop({items: filteredItems, dropOperation}); } - if (target.dropPosition !== 'on') { - if (!isInternalDrop && onInsert) { - await onInsert({items: filteredItems, dropOperation, target: {key: target.key, dropPosition: target.dropPosition}}); + if (target.type === 'item') { + if (target.dropPosition === 'on' && onItemDrop) { + await onItemDrop({items: filteredItems, dropOperation, isInternal, target}); } - if (isInternalDrop && onReorder) { - await onReorder({keys: draggingKeys, dropOperation, target: {key: target.key, dropPosition: target.dropPosition}}); + if (target.dropPosition !== 'on') { + if (!isInternal && onInsert) { + await onInsert({items: filteredItems, dropOperation, target}); + } + + if (isInternal && onReorder) { + await onReorder({keys: draggingKeys, dropOperation, target}); + } } } } @@ -122,9 +124,9 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: autoScroll.move(e.x, e.y); }, getDropOperationForPoint(types, allowedOperations, x, y) { - let {draggingCollectionRef, draggingKeys, dropCollectionRef} = globalDndState; - let isInternalDrop = draggingCollectionRef?.current != null && draggingCollectionRef?.current === ref?.current; - let isValidDropTarget = (target) => state.getDropOperation({target, types, allowedOperations, isInternalDrop, draggingKeys}) !== 'cancel'; + let {draggingKeys, dropCollectionRef} = globalDndState; + let isInternal = isInternalDropOperation(ref); + let isValidDropTarget = (target) => state.getDropOperation({target, types, allowedOperations, isInternal, draggingKeys}) !== 'cancel'; let target = props.dropTargetDelegate.getDropTargetFromPoint(x, y, isValidDropTarget); if (!target) { localState.dropOperation = 'cancel'; @@ -132,12 +134,12 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: return 'cancel'; } - localState.dropOperation = state.getDropOperation({target, types, allowedOperations, isInternalDrop, draggingKeys}); + localState.dropOperation = state.getDropOperation({target, types, allowedOperations, isInternal, draggingKeys}); // If the target doesn't accept the drop, see if the root accepts it instead. if (localState.dropOperation === 'cancel') { let rootTarget: DropTarget = {type: 'root'}; - let dropOperation = state.getDropOperation({target: rootTarget, types, allowedOperations, isInternalDrop, draggingKeys}); + let dropOperation = state.getDropOperation({target: rootTarget, types, allowedOperations, isInternal, draggingKeys}); if (dropOperation !== 'cancel') { target = rootTarget; localState.dropOperation = dropOperation; @@ -375,15 +377,15 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: ): DropTarget => { let seenRoot = 0; let operation: DropOperation; - let {draggingCollectionRef, draggingKeys} = globalDndState; - let isInternalDrop = draggingCollectionRef?.current != null && draggingCollectionRef?.current === ref?.current; + let {draggingKeys} = globalDndState; + let isInternal = isInternalDropOperation(ref); do { let nextTarget = getNextTarget(target, wrap); if (!nextTarget) { return null; } target = nextTarget; - operation = localState.state.getDropOperation({target: nextTarget, types, allowedOperations: allowedDropOperations, isInternalDrop, draggingKeys}); + operation = localState.state.getDropOperation({target: nextTarget, types, allowedOperations: allowedDropOperations, isInternal, draggingKeys}); if (target.type === 'root') { seenRoot++; } @@ -404,9 +406,9 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: element: ref.current, getDropOperation(types, allowedOperations) { if (localState.state.target) { - let {draggingCollectionRef, draggingKeys} = globalDndState; - let isInternalDrop = draggingCollectionRef?.current != null && draggingCollectionRef?.current === ref?.current; - return localState.state.getDropOperation({target: localState.state.target, types, allowedOperations, isInternalDrop, draggingKeys}); + let {draggingKeys} = globalDndState; + let isInternal = isInternalDropOperation(ref); + return localState.state.getDropOperation({target: localState.state.target, types, allowedOperations, isInternal, draggingKeys}); } // Check if any of the targets accept the drop. @@ -418,7 +420,7 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: let types = getTypes(drag.items); let selectionManager = localState.state.selectionManager; let target: DropTarget; - // Update the drop collection ref tracker for useDroppableItem's getDropOperation isInternalDrop check + // Update the drop collection ref tracker for useDroppableItem's getDropOperation isInternal check setDropCollectionRef(ref); // When entering the droppable collection for the first time, the default drop target @@ -452,10 +454,10 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: dropPosition }; - let {draggingCollectionRef, draggingKeys} = globalDndState; - let isInternalDrop = draggingCollectionRef?.current != null && draggingCollectionRef?.current === ref?.current; + let {draggingKeys} = globalDndState; + let isInternal = isInternalDropOperation(ref); // If the default target is not valid, find the next one that is. - if (localState.state.getDropOperation({target, types, allowedOperations: drag.allowedDropOperations, isInternalDrop, draggingKeys}) === 'cancel') { + if (localState.state.getDropOperation({target, types, allowedOperations: drag.allowedDropOperations, isInternal, draggingKeys}) === 'cancel') { target = nextValidTarget(target, types, drag.allowedDropOperations, getNextTarget, false) ?? nextValidTarget(target, types, drag.allowedDropOperations, getPreviousTarget, false); } @@ -556,8 +558,8 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: // If the target does not accept the drop, find the next valid target. // If no next valid target, find the previous valid target. let {draggingCollectionRef, draggingKeys} = globalDndState; - let isInternalDrop = draggingCollectionRef?.current === ref?.current; - let operation = localState.state.getDropOperation({target, types, allowedOperations: drag.allowedDropOperations, isInternalDrop, draggingKeys}); + let isInternal = draggingCollectionRef?.current === ref?.current; + let operation = localState.state.getDropOperation({target, types, allowedOperations: drag.allowedDropOperations, isInternal, draggingKeys}); if (operation === 'cancel') { target = nextValidTarget(target, types, drag.allowedDropOperations, getNextTarget, false) ?? nextValidTarget(target, types, drag.allowedDropOperations, getPreviousTarget, false); @@ -599,9 +601,9 @@ export function useDroppableCollection(props: DroppableCollectionOptions, state: // If the target does not accept the drop, find the previous valid target. // If no next valid target, find the next valid target. - let {draggingCollectionRef, draggingKeys} = globalDndState; - let isInternalDrop = draggingCollectionRef?.current != null && draggingCollectionRef?.current === ref?.current; - let operation = localState.state.getDropOperation({target, types, allowedOperations: drag.allowedDropOperations, isInternalDrop, draggingKeys}); + let {draggingKeys} = globalDndState; + let isInternal = isInternalDropOperation(ref); + let operation = localState.state.getDropOperation({target, types, allowedOperations: drag.allowedDropOperations, isInternal, draggingKeys}); if (operation === 'cancel') { target = nextValidTarget(target, types, drag.allowedDropOperations, getPreviousTarget, false) ?? nextValidTarget(target, types, drag.allowedDropOperations, getNextTarget, false); diff --git a/packages/@react-aria/dnd/src/useDroppableItem.ts b/packages/@react-aria/dnd/src/useDroppableItem.ts index 2a25efeb4ef..9dc250f601c 100644 --- a/packages/@react-aria/dnd/src/useDroppableItem.ts +++ b/packages/@react-aria/dnd/src/useDroppableItem.ts @@ -13,7 +13,7 @@ import * as DragManager from './DragManager'; import {DroppableCollectionState} from '@react-stately/dnd'; import {DropTarget} from '@react-types/shared'; -import {getTypes, globalDndState} from './utils'; +import {getTypes, globalDndState, isInternalDropOperation} from './utils'; import {HTMLAttributes, RefObject, useEffect} from 'react'; import {useVirtualDrop} from './useVirtualDrop'; @@ -35,13 +35,13 @@ export function useDroppableItem(options: DroppableItemOptions, state: Droppable element: ref.current, target: options.target, getDropOperation(types, allowedOperations) { - let {draggingCollectionRef, draggingKeys, dropCollectionRef} = globalDndState; - let isInternalDrop = draggingCollectionRef?.current != null && draggingCollectionRef?.current === dropCollectionRef?.current; + let {draggingKeys} = globalDndState; + let isInternal = isInternalDropOperation(); return state.getDropOperation({ target: options.target, types, allowedOperations, - isInternalDrop, + isInternal, draggingKeys }); } @@ -50,13 +50,13 @@ export function useDroppableItem(options: DroppableItemOptions, state: Droppable }, [ref, options.target, state]); let dragSession = DragManager.useDragSession(); - let {draggingCollectionRef, draggingKeys, dropCollectionRef} = globalDndState; - let isInternalDrop = draggingCollectionRef?.current != null && draggingCollectionRef?.current === dropCollectionRef?.current; + let {draggingKeys} = globalDndState; + let isInternal = isInternalDropOperation(); let isValidDropTarget = dragSession && state.getDropOperation({ target: options.target, types: getTypes(dragSession.dragTarget.items), allowedOperations: dragSession.dragTarget.allowedDropOperations, - isInternalDrop, + isInternal, draggingKeys }) !== 'cancel'; diff --git a/packages/@react-aria/dnd/src/utils.ts b/packages/@react-aria/dnd/src/utils.ts index 557305a8d16..bdf20f65919 100644 --- a/packages/@react-aria/dnd/src/utils.ts +++ b/packages/@react-aria/dnd/src/utils.ts @@ -18,6 +18,7 @@ import {Key, RefObject} from 'react'; import {useId} from '@react-aria/utils'; const droppableCollectionIds = new WeakMap(); +export const DIRECTORY_DRAG_TYPE = Symbol(); export function useDroppableCollectionId(state: DroppableCollectionState) { let id = useId(); @@ -159,12 +160,12 @@ export class DragTypes implements IDragTypes { this.includesUnknownTypes = !hasFiles && dataTransfer.types.includes('Files'); } - has(type: string) { - if (this.includesUnknownTypes || (type === 'directory' && this.types.has(GENERIC_TYPE))) { + has(type: string | symbol) { + if (this.includesUnknownTypes || (type === DIRECTORY_DRAG_TYPE && this.types.has(GENERIC_TYPE))) { return true; } - return this.types.has(type); + return typeof type === 'string' && this.types.has(type); } } @@ -336,6 +337,13 @@ export function setGlobalDnDState(state: DnDState) { globalDndState = state; } +// Util function to check if the current dragging collection ref is the same as the current targeted droppable collection ref. +// Allows a droppable ref arg in case the global drop collection ref hasn't been set +export function isInternalDropOperation(ref?: RefObject) { + let {draggingCollectionRef, dropCollectionRef} = globalDndState; + return draggingCollectionRef?.current != null && draggingCollectionRef.current === (ref?.current || dropCollectionRef?.current); +} + type DropEffect = 'none' | 'copy' | 'link' | 'move'; export let globalDropEffect: DropEffect; export function setGlobalDropEffect(dropEffect: DropEffect) { diff --git a/packages/@react-aria/dnd/stories/DroppableGrid.tsx b/packages/@react-aria/dnd/stories/DroppableGrid.tsx index 8fff261ff32..cda62a0f7eb 100644 --- a/packages/@react-aria/dnd/stories/DroppableGrid.tsx +++ b/packages/@react-aria/dnd/stories/DroppableGrid.tsx @@ -165,7 +165,8 @@ const DroppableGrid = React.forwardRef(function (props: any, ref) { selectionManager: gridState.selectionManager, getDropOperation: props.getDropOperation || defaultGetDropOperation, onDropEnter: props.onDropEnter, - onDropExit: props.onDropExit + onDropExit: props.onDropExit, + onDrop: props.onDrop }); let {collectionProps} = useDroppableCollection({ diff --git a/packages/@react-aria/dnd/test/useDraggableCollection.test.js b/packages/@react-aria/dnd/test/useDraggableCollection.test.js index f1924f3f407..92cc3dd754b 100644 --- a/packages/@react-aria/dnd/test/useDraggableCollection.test.js +++ b/packages/@react-aria/dnd/test/useDraggableCollection.test.js @@ -121,7 +121,7 @@ describe('useDraggableCollection', () => { y: 2, dropOperation: 'move', keys: new Set(['bar']), - isInternalDrop: false + isInternal: false }); cells = within(grid).getAllByRole('gridcell'); @@ -218,7 +218,7 @@ describe('useDraggableCollection', () => { y: 2, dropOperation: 'move', keys: new Set(['foo', 'bar']), - isInternalDrop: false + isInternal: false }); cells = within(grid).getAllByRole('gridcell'); @@ -302,7 +302,7 @@ describe('useDraggableCollection', () => { y: 2, dropOperation: 'move', keys: new Set(['bar']), - isInternalDrop: false + isInternal: false }); cells = within(grid).getAllByRole('gridcell'); @@ -391,7 +391,7 @@ describe('useDraggableCollection', () => { y: 25, dropOperation: 'move', keys: new Set(['bar']), - isInternalDrop: false + isInternal: false }); cells = within(grid).getAllByRole('gridcell'); @@ -490,7 +490,7 @@ describe('useDraggableCollection', () => { y: 25, dropOperation: 'move', keys: new Set(['foo', 'bar']), - isInternalDrop: false + isInternal: false }); cells = within(grid).getAllByRole('gridcell'); @@ -577,7 +577,7 @@ describe('useDraggableCollection', () => { y: 25, dropOperation: 'move', keys: new Set(['bar']), - isInternalDrop: false + isInternal: false }); cells = within(grid).getAllByRole('gridcell'); @@ -671,7 +671,7 @@ describe('useDraggableCollection', () => { y: 25, dropOperation: 'move', keys: new Set(['foo', 'bar']), - isInternalDrop: false + isInternal: false }); }); @@ -767,7 +767,7 @@ describe('useDraggableCollection', () => { y: 25, dropOperation: 'move', keys: new Set(['foo', 'bar']), - isInternalDrop: false + isInternal: false }); }); }); @@ -849,7 +849,7 @@ describe('useDraggableCollection', () => { y: 25, dropOperation: 'move', keys: new Set(['bar']), - isInternalDrop: false + isInternal: false }); cells = within(grid).getAllByRole('gridcell'); @@ -936,7 +936,7 @@ describe('useDraggableCollection', () => { y: 25, dropOperation: 'move', keys: new Set(['foo', 'bar']), - isInternalDrop: false + isInternal: false }); cells = within(grid).getAllByRole('gridcell'); @@ -1013,7 +1013,7 @@ describe('useDraggableCollection', () => { y: 25, dropOperation: 'move', keys: new Set(['bar']), - isInternalDrop: false + isInternal: false }); cells = within(grid).getAllByRole('gridcell'); @@ -1100,7 +1100,7 @@ describe('useDraggableCollection', () => { y: 25, dropOperation: 'move', keys: new Set(['foo', 'bar']), - isInternalDrop: false + isInternal: false }); delete window.ontouchstart; @@ -1200,7 +1200,7 @@ describe('useDraggableCollection', () => { y: 25, dropOperation: 'move', keys: new Set(['foo', 'bar']), - isInternalDrop: false + isInternal: false }); delete window.ontouchstart; diff --git a/packages/@react-aria/gridlist/package.json b/packages/@react-aria/gridlist/package.json index 3d8ca16afb0..b08f0f63e1e 100644 --- a/packages/@react-aria/gridlist/package.json +++ b/packages/@react-aria/gridlist/package.json @@ -26,7 +26,6 @@ "@react-aria/utils": "^3.13.3", "@react-stately/list": "^3.5.3", "@react-types/checkbox": "^3.3.3", - "@react-types/list": "^3.0.0", "@react-types/shared": "^3.14.1" }, "peerDependencies": { diff --git a/packages/@react-aria/gridlist/src/index.ts b/packages/@react-aria/gridlist/src/index.ts index 15aeb817b0b..740ca23b200 100644 --- a/packages/@react-aria/gridlist/src/index.ts +++ b/packages/@react-aria/gridlist/src/index.ts @@ -14,6 +14,6 @@ export {useGridList} from './useGridList'; export {useGridListItem} from './useGridListItem'; export {useGridListSelectionCheckbox} from './useGridListSelectionCheckbox'; -export type {AriaGridListOptions, GridListAria} from './useGridList'; +export type {AriaGridListOptions, AriaGridListProps, GridListAria, GridListProps} from './useGridList'; export type {AriaGridListItemOptions, GridListItemAria} from './useGridListItem'; export type {AriaGridSelectionCheckboxProps, GridSelectionCheckboxAria} from '@react-aria/grid'; diff --git a/packages/@react-aria/gridlist/src/useGridList.ts b/packages/@react-aria/gridlist/src/useGridList.ts index 916d509c363..c35350a480a 100644 --- a/packages/@react-aria/gridlist/src/useGridList.ts +++ b/packages/@react-aria/gridlist/src/useGridList.ts @@ -10,15 +10,34 @@ * governing permissions and limitations under the License. */ -import {AriaGridListProps} from '@react-types/list'; -import {DOMAttributes, KeyboardDelegate} from '@react-types/shared'; +import { + AriaLabelingProps, + CollectionBase, + DisabledBehavior, + DOMAttributes, + DOMProps, + KeyboardDelegate, + MultipleSelection +} from '@react-types/shared'; import {filterDOMProps, mergeProps, useId} from '@react-aria/utils'; +import {Key, RefObject} from 'react'; import {listMap} from './utils'; import {ListState} from '@react-stately/list'; -import {RefObject} from 'react'; import {useGridSelectionAnnouncement, useHighlightSelectionDescription} from '@react-aria/grid'; import {useSelectableList} from '@react-aria/selection'; +export interface GridListProps extends CollectionBase, MultipleSelection { + /** + * Handler that is called when a user performs an action on an item. The exact user event depends on + * the collection's `selectionBehavior` prop and the interaction modality. + */ + onAction?: (key: Key) => void, + /** Whether `disabledKeys` applies to all interactions, or only selection. */ + disabledBehavior?: DisabledBehavior +} + +export interface AriaGridListProps extends GridListProps, DOMProps, AriaLabelingProps {} + export interface AriaGridListOptions extends Omit, 'children'> { /** Whether the list uses virtual scrolling. */ isVirtualized?: boolean, diff --git a/packages/@react-aria/interactions/src/utils.ts b/packages/@react-aria/interactions/src/utils.ts index 2460aefaf43..ca4f4cb1730 100644 --- a/packages/@react-aria/interactions/src/utils.ts +++ b/packages/@react-aria/interactions/src/utils.ts @@ -33,7 +33,7 @@ export function isVirtualClick(event: MouseEvent | PointerEvent): boolean { // Android TalkBack's detail value varies depending on the event listener providing the event so we have specific logic here instead // If pointerType is defined, event is from a click listener. For events from mousedown listener, detail === 0 is a sufficient check // to detect TalkBack virtual clicks. - if (isAndroid() && (event as PointerEvent).pointerType != null) { + if (isAndroid() && (event as PointerEvent).pointerType) { return event.type === 'click' && event.buttons === 1; } diff --git a/packages/@react-spectrum/dnd/src/index.ts b/packages/@react-spectrum/dnd/src/index.ts index 360debc3255..8dff9e92255 100644 --- a/packages/@react-spectrum/dnd/src/index.ts +++ b/packages/@react-spectrum/dnd/src/index.ts @@ -14,3 +14,4 @@ export type {DnDOptions, DnDHooks} from './useDnDHooks'; export {useDnDHooks} from './useDnDHooks'; +export {DIRECTORY_DRAG_TYPE} from '@react-aria/dnd'; diff --git a/packages/@react-spectrum/dnd/src/useDnDHooks.ts b/packages/@react-spectrum/dnd/src/useDnDHooks.ts index 944ee649b42..c1f01508b21 100644 --- a/packages/@react-spectrum/dnd/src/useDnDHooks.ts +++ b/packages/@react-spectrum/dnd/src/useDnDHooks.ts @@ -68,45 +68,48 @@ export interface DnDOptions extends Omit { + let { + onDrop, + onInsert, + onItemDrop, + onReorder, + onRootDrop, + getItems + } = options; - let dragHooks: DragHooks = useMemo(() => ({ - useDraggableCollectionState(props: DraggableCollectionStateOptions) { - return useDraggableCollectionState({...props, ...options}); - }, - useDraggableCollection, - useDraggableItem, - DragPreview - }), [options]); + let isDraggable = !!getItems; + let isDroppable = !!(onDrop || onInsert || onItemDrop || onReorder || onRootDrop); - let dropHooks: DropHooks = useMemo(() => ({ - useDroppableCollectionState(props) { - return useDroppableCollectionState({...props, ...options}); - }, - useDroppableItem, - useDroppableCollection(props, state, ref) { - return useDroppableCollection({...props, ...options}, state, ref); - }, - useDropIndicator - }), [options]); + let hooks = {} as DragHooks & DropHooks & {isVirtualDragging?: () => boolean}; + if (isDraggable) { + hooks.useDraggableCollectionState = function useDraggableCollectionStateOverride(props: DraggableCollectionStateOptions) { + return useDraggableCollectionState({...props, ...options}); + }; + hooks.useDraggableCollection = useDraggableCollection; + hooks.useDraggableItem = useDraggableItem; + hooks.DragPreview = DragPreview; + } - let isDraggable = !!getItems; - let isDroppable = !!(onDrop || onInsert || onItemDrop || onReorder || onRootDrop); + if (isDroppable) { + hooks.useDroppableCollectionState = function useDroppableCollectionStateOverride(props: DroppableCollectionStateOptions) { + return useDroppableCollectionState({...props, ...options}); + }, + hooks.useDroppableItem = useDroppableItem; + hooks.useDroppableCollection = function useDroppableCollectionOverride(props: DroppableCollectionOptions, state: DroppableCollectionState, ref: RefObject) { + return useDroppableCollection({...props, ...options}, state, ref); + }; + hooks.useDropIndicator = useDropIndicator; + } - let mergedHooks = { - ...(isDraggable ? dragHooks : {}), - ...(isDroppable ? dropHooks : {}), - ...(isDraggable || isDroppable ? {isVirtualDragging} : {}) - }; + if (isDraggable || isDroppable) { + hooks.isVirtualDragging = isVirtualDragging; + } + + return hooks; + }, [options]); return { - dndHooks: mergedHooks + dndHooks: dndHooks }; } diff --git a/packages/@react-spectrum/list/package.json b/packages/@react-spectrum/list/package.json index 6cfbb0dd678..e2fbed3d55c 100644 --- a/packages/@react-spectrum/list/package.json +++ b/packages/@react-spectrum/list/package.json @@ -41,7 +41,6 @@ "@react-aria/virtualizer": "^3.5.0", "@react-aria/visually-hidden": "^3.4.1", "@react-spectrum/checkbox": "^3.5.1", - "@react-spectrum/dnd": "3.0.0-alpha.6", "@react-spectrum/layout": "^3.4.1", "@react-spectrum/progress": "^3.3.1", "@react-spectrum/text": "^3.3.1", @@ -51,7 +50,6 @@ "@react-stately/list": "^3.5.3", "@react-stately/virtualizer": "^3.3.0", "@react-types/grid": "^3.1.3", - "@react-types/list": "^3.0.0", "@react-types/shared": "^3.14.1", "@spectrum-icons/ui": "^3.3.2", "react-transition-group": "^2.2.0" @@ -60,7 +58,8 @@ "@adobe/spectrum-css-temp": "^3.0.0-alpha.1", "@react-aria/dnd": "3.0.0-alpha.12", "@react-stately/dnd": "3.0.0-alpha.10", - "@react-spectrum/button": "^3.8.1" + "@react-spectrum/button": "^3.8.1", + "@react-spectrum/dnd": "3.0.0-alpha.6" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", diff --git a/packages/@react-spectrum/list/src/DragPreview.tsx b/packages/@react-spectrum/list/src/DragPreview.tsx index ecd8dc21a4f..77e2e1550fe 100644 --- a/packages/@react-spectrum/list/src/DragPreview.tsx +++ b/packages/@react-spectrum/list/src/DragPreview.tsx @@ -14,7 +14,7 @@ import {Grid} from '@react-spectrum/layout'; import {GridNode} from '@react-types/grid'; import listStyles from './styles.css'; import React from 'react'; -import {SpectrumListViewProps} from '@react-types/list'; +import type {SpectrumListViewProps} from './ListView'; import {Text} from '@react-spectrum/text'; interface DragPreviewProps { diff --git a/packages/@react-spectrum/list/src/ListView.tsx b/packages/@react-spectrum/list/src/ListView.tsx index f7ff450673e..d7896cecdb5 100644 --- a/packages/@react-spectrum/list/src/ListView.tsx +++ b/packages/@react-spectrum/list/src/ListView.tsx @@ -9,12 +9,14 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ + +import {AriaGridListProps, useGridList} from '@react-aria/gridlist'; +import {AsyncLoadable, DOMRef, LoadingState, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import type {DnDHooks} from '@react-spectrum/dnd'; -import {DOMRef, LoadingState} from '@react-types/shared'; import type {DraggableCollectionState, DroppableCollectionState} from '@react-stately/dnd'; import type {DroppableCollectionResult} from '@react-aria/dnd'; -import {filterDOMProps, useLayoutEffect} from '@react-aria/utils'; +import {filterDOMProps, mergeProps, useLayoutEffect} from '@react-aria/utils'; import {FocusRing, FocusScope} from '@react-aria/focus'; import InsertionIndicator from './InsertionIndicator'; // @ts-ignore @@ -23,17 +25,43 @@ import {ListLayout} from '@react-stately/layout'; import {ListState, useListState} from '@react-stately/list'; import listStyles from './styles.css'; import {ListViewItem} from './ListViewItem'; -import {mergeProps} from '@react-aria/utils'; import {ProgressCircle} from '@react-spectrum/progress'; import React, {Key, ReactElement, useContext, useMemo, useRef, useState} from 'react'; import RootDropIndicator from './RootDropIndicator'; import {DragPreview as SpectrumDragPreview} from './DragPreview'; -import {SpectrumListViewProps} from '@react-types/list'; import {useCollator, useLocalizedStringFormatter} from '@react-aria/i18n'; -import {useGridList} from '@react-aria/gridlist'; import {useProvider} from '@react-spectrum/provider'; import {Virtualizer} from '@react-aria/virtualizer'; +export interface SpectrumListViewProps extends AriaGridListProps, StyleProps, SpectrumSelectionProps, Omit { + /** + * Sets the amount of vertical padding within each cell. + * @default 'regular' + */ + density?: 'compact' | 'regular' | 'spacious', + /** Whether the ListView should be displayed with a quiet style. */ + isQuiet?: boolean, + /** The current loading state of the ListView. Determines whether or not the progress circle should be shown. */ + loadingState?: LoadingState, + /** + * Sets the text behavior for the row contents. + * @default 'truncate' + */ + overflowMode?: 'truncate' | 'wrap', + /** Sets what the ListView should render when there is no content to display. */ + renderEmptyState?: () => JSX.Element, + /** + * Handler that is called when a user performs an action on an item. The exact user event depends on + * the collection's `selectionStyle` prop and the interaction modality. + */ + onAction?: (key: Key) => void, + /** + * The drag and drop hooks returned by `useDnDHooks` used to enable drag and drop behavior for the ListView. + * @private + */ + dndHooks?: DnDHooks['dndHooks'] +} + interface ListViewContextValue { state: ListState, dragState: DraggableCollectionState, diff --git a/packages/@react-spectrum/list/src/index.ts b/packages/@react-spectrum/list/src/index.ts index db947ee39a5..0b3c13f524f 100644 --- a/packages/@react-spectrum/list/src/index.ts +++ b/packages/@react-spectrum/list/src/index.ts @@ -1,3 +1,15 @@ +/* + * Copyright 2022 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. + */ + export {ListView} from './ListView'; export {Item} from '@react-stately/collections'; -export type {SpectrumListViewProps} from '@react-types/list'; +export type {SpectrumListViewProps} from './ListView'; diff --git a/packages/@react-spectrum/list/stories/ListView.stories.tsx b/packages/@react-spectrum/list/stories/ListView.stories.tsx index 29587e33f4b..8b60ab16024 100644 --- a/packages/@react-spectrum/list/stories/ListView.stories.tsx +++ b/packages/@react-spectrum/list/stories/ListView.stories.tsx @@ -9,6 +9,7 @@ import {chain} from '@react-aria/utils'; import {Content} from '@react-spectrum/view'; import Copy from '@spectrum-icons/workflow/Copy'; import Delete from '@spectrum-icons/workflow/Delete'; +import {DIRECTORY_DRAG_TYPE} from '@react-aria/dnd'; import {Droppable} from '@react-aria/dnd/stories/dnd.stories'; import Edit from '@spectrum-icons/workflow/Edit'; import File from '@spectrum-icons/illustrations/File'; @@ -462,7 +463,7 @@ storiesOf('ListView/Drag and Drop/Util Handlers', module) 'allows directories and files from finder', args => ( - ), {description: {data: 'The first list should allow only directory drops. The second list should allow all drag type drops (directory, files).'}} + ), {description: {data: 'The first list should allow only directory drops (e.g. folders from finder). The second list should allow all drag type drops (directory/files from finder, any drag items).'}} ) .add( 'complex drag between lists', @@ -477,6 +478,23 @@ storiesOf('ListView/Drag and Drop/Util Handlers', module) }} /> ), {description: {data: 'The first list should allow dragging and drops into its folder, but disallow reorder operations. External root drops should be placed at the end of the list. The second list should allow all operations and root drops should be placed at the top of the list. Move and copy operations are allowed. The invalid drag item should be able to be dropped in either list if accompanied by other valid drag items.'}} ) + .add( + 'using getDropOperations to determine default drop operation', + args => ( + allowedOperations.filter(op => op !== 'move')[0], + getAllowedDropOperations: () => ['link'] + }} + secondListDnDOptions={{ + onDragStart: action('dragStartList2'), + getDropOperation: (_, __, allowedOperations) => allowedOperations.filter(op => op !== 'move')[0], + getAllowedDropOperations: () => ['move', 'copy', 'link'] + }} /> + ), {description: {data: 'Dragging from the first to the second list should automatically set a link operation and all other drop operations should be disabled. Dragging from the second to first list should support copy and link operations, with copy being the default.'}} + ) .add( 'util handlers overridden by onDrop and getDropOperations', args => , @@ -1461,16 +1479,16 @@ function ItemDropExampleUtilHandlers(props) { let { items, target, - isInternalDrop, + isInternal, dropOperation } = e; action('onItemDrop')(e); - if (isInternalDrop) { + if (isInternal) { let processedItems = await itemProcessor(items, acceptedDragTypes); let targetItem = list.getItem(target.key); if (targetItem?.childNodes != null) { list.update(target.key, {...targetItem, childNodes: [...targetItem.childNodes, ...processedItems]}); - if (isInternalDrop && dropOperation === 'move') { + if (isInternal && dropOperation === 'move') { let keysToRemove = processedItems.map(item => item.identifier); list.remove(...keysToRemove); } @@ -1528,11 +1546,11 @@ function RootDropExampleUtilHandlers(props) { onDragEnd: (e) => { let { dropOperation, - isInternalDrop, + isInternal, keys } = e; action('onDragEnd')(e); - if (dropOperation === 'move' && !isInternalDrop) { + if (dropOperation === 'move' && !isInternal) { list1.remove(...keys); } }, @@ -1619,11 +1637,11 @@ function InsertExampleUtilHandlers(props) { onDragEnd: (e) => { let { dropOperation, - isInternalDrop, + isInternal, keys } = e; action('onDragEnd')(e); - if (dropOperation === 'move' && !isInternalDrop) { + if (dropOperation === 'move' && !isInternal) { list1.remove(...keys); } }, @@ -1708,7 +1726,7 @@ function FinderDropUtilHandlers(props) { }); let {dndHooks: list1Hooks} = useDnDHooks({ - acceptedDragTypes: ['directory'], + acceptedDragTypes: [DIRECTORY_DRAG_TYPE], onInsert: async (e) => { action('onInsertList1')(e); }, @@ -1788,28 +1806,6 @@ export function DragBetweenListsComplex(props) { }); let acceptedDragTypes = ['file', 'folder', 'text/plain']; - let itemProcessor = async (items, acceptedDragTypes) => { - let processedItems = []; - let text; - for (let item of items) { - for (let type of acceptedDragTypes) { - // TODO: this logic will need to be updated for files/directories, - if (item.kind === 'text' && item.types.has(type)) { - text = await item.getText(type); - processedItems.push(JSON.parse(text)); - break; - } else if (item.types.size === 1 && item.types.has('text/plain')) { - // Fallback for Chrome Android case: https://bugs.chromium.org/p/chromium/issues/detail?id=1293803 - // Multiple drag items are contained in a single string so we need to split them out - text = await item.getText('text/plain'); - processedItems = text.split('\n').map(val => JSON.parse(val)); - break; - } - } - } - return processedItems; - }; - // List 1 should allow on item drops and external drops, but disallow reordering/internal drops let {dndHooks: dndHooksList1} = useDnDHooks({ getItems: (keys) => [...keys].map(key => { @@ -1843,7 +1839,7 @@ export function DragBetweenListsComplex(props) { let { items, target, - isInternalDrop, + isInternal, dropOperation } = e; action('onItemDropList1')(e); @@ -1851,7 +1847,7 @@ export function DragBetweenListsComplex(props) { let targetItem = list1.getItem(target.key); list1.update(target.key, {...targetItem, childNodes: [...targetItem.childNodes, ...processedItems]}); - if (isInternalDrop && dropOperation === 'move') { + if (isInternal && dropOperation === 'move') { // TODO test this, perhaps it would be easier to also pass the draggedKeys to onItemDrop instead? // TODO: dig into other libraries to see how they handle this let keysToRemove = processedItems.map(item => item.identifier); @@ -1862,11 +1858,11 @@ export function DragBetweenListsComplex(props) { onDragEnd: (e) => { let { dropOperation, - isInternalDrop, + isInternal, keys } = e; action('onDragEndList1')(e); - if (dropOperation === 'move' && !isInternalDrop) { + if (dropOperation === 'move' && !isInternal) { list1.remove(...keys); } }, @@ -1942,7 +1938,7 @@ export function DragBetweenListsComplex(props) { let { items, target, - isInternalDrop, + isInternal, dropOperation } = e; action('onItemDropList2')(e); @@ -1950,7 +1946,7 @@ export function DragBetweenListsComplex(props) { let targetItem = list2.getItem(target.key); list2.update(target.key, {...targetItem, childNodes: [...targetItem.childNodes, ...processedItems]}); - if (isInternalDrop && dropOperation === 'move') { + if (isInternal && dropOperation === 'move') { let keysToRemove = processedItems.map(item => item.identifier); list2.remove(...keysToRemove); } @@ -1959,11 +1955,11 @@ export function DragBetweenListsComplex(props) { onDragEnd: (e) => { let { dropOperation, - isInternalDrop, + isInternal, keys } = e; action('onDragEndList2')(e); - if (dropOperation === 'move' && !isInternalDrop) { + if (dropOperation === 'move' && !isInternal) { let keysToRemove = [...keys].filter(key => list2.getItem(key).type !== 'unique_type'); list2.remove(...keysToRemove); } diff --git a/packages/@react-spectrum/list/test/ListViewDnd.test.js b/packages/@react-spectrum/list/test/ListViewDnd.test.js index 154b8a94263..748f79432df 100644 --- a/packages/@react-spectrum/list/test/ListViewDnd.test.js +++ b/packages/@react-spectrum/list/test/ListViewDnd.test.js @@ -14,6 +14,7 @@ jest.mock('@react-aria/live-announcer'); import {act, fireEvent, installPointerEvent, render as renderComponent, waitFor, within} from '@react-spectrum/test-utils'; import {CUSTOM_DRAG_TYPE} from '@react-aria/dnd/src/constants'; import {DataTransfer, DataTransferItem, DragEvent, FileSystemDirectoryEntry, FileSystemFileEntry} from '@react-aria/dnd/test/mocks'; +import {DIRECTORY_DRAG_TYPE} from '@react-aria/dnd'; import {DragBetweenListsComplex, DragBetweenListsExample, DragBetweenListsRootOnlyExample, DragExample, DragIntoItemExample, ReorderExample} from '../stories/ListView.stories'; import {Droppable} from '@react-aria/dnd/test/examples'; import {globalDndState} from '@react-aria/dnd/src/utils'; @@ -223,7 +224,7 @@ describe('ListView', function () { x: 1, y: 1, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); }); @@ -308,7 +309,7 @@ describe('ListView', function () { x: 1, y: 1, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); }); @@ -635,7 +636,7 @@ describe('ListView', function () { it('should reset the global drop state on drop if a dragged item is a non RSP drag target', function () { let {getAllByRole} = render( - 'copy', onDrop}} /> + 'copy', onDrop, acceptedDragTypes: 'all'}} /> ); let grids = getAllByRole('grid'); @@ -752,7 +753,8 @@ describe('ListView', function () { dropOperation: 'move', target: { key: '1', - dropPosition: 'before' + dropPosition: 'before', + type: 'item' }, items: [ { @@ -799,7 +801,8 @@ describe('ListView', function () { expect(onReorder).toHaveBeenCalledWith({ target: { key: '4', - dropPosition: 'after' + dropPosition: 'after', + type: 'item' }, keys: new Set(['1', '2']), dropOperation: 'copy' @@ -823,7 +826,8 @@ describe('ListView', function () { expect(onReorder).toHaveBeenCalledWith({ target: { key: '4', - dropPosition: 'after' + dropPosition: 'after', + type: 'item' }, keys: new Set(['1', '2']), dropOperation: 'move' @@ -892,7 +896,7 @@ describe('ListView', function () { x: 1, y: 185, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); expect(onReorder).toHaveBeenCalledTimes(0); expect(onItemDrop).toHaveBeenCalledTimes(1); @@ -901,9 +905,10 @@ describe('ListView', function () { expect(onItemDrop).toHaveBeenCalledWith({ target: { key: '5', - dropPosition: 'on' + dropPosition: 'on', + type: 'item' }, - isInternalDrop: false, + isInternal: false, dropOperation: 'move', items: [ { @@ -937,9 +942,10 @@ describe('ListView', function () { expect(onItemDrop).toHaveBeenCalledWith({ target: { key: '3', - dropPosition: 'on' + dropPosition: 'on', + type: 'item' }, - isInternalDrop: true, + isInternal: true, dropOperation: 'move', items: [ { @@ -985,6 +991,56 @@ describe('ListView', function () { expect(onInsert).toHaveBeenCalledTimes(0); }); + it('should default acceptedDragTypes to "all" if not provided by the user', function () { + let shouldAcceptItemDrop = jest.fn(); + shouldAcceptItemDrop.mockReturnValue(true); + let {getAllByRole} = render( + + ); + + let grids = getAllByRole('grid'); + expect(grids).toHaveLength(2); + + let dropTarget = within(grids[0]).getAllByRole('row')[4]; + let list2Rows = within(grids[1]).getAllByRole('row'); + dragBetweenLists(list2Rows, dropTarget, 1, 185); + + expect(onReorder).toHaveBeenCalledTimes(0); + expect(onItemDrop).toHaveBeenCalledTimes(1); + expect(onRootDrop).toHaveBeenCalledTimes(0); + expect(onInsert).toHaveBeenCalledTimes(0); + expect(onItemDrop).toHaveBeenCalledWith({ + target: { + key: '5', + dropPosition: 'on', + type: 'item' + }, + isInternal: false, + dropOperation: 'move', + items: [ + { + kind: 'text', + types: new Set(['text/plain', 'folder']), + getText: expect.any(Function) + }, + { + kind: 'text', + types: new Set(['text/plain', 'file']), + getText: expect.any(Function) + } + ] + }); + + // Called twice from getDropOperation and twice in onDrop when performing item filtering + expect(shouldAcceptItemDrop).toHaveBeenCalledTimes(4); + expect(shouldAcceptItemDrop.mock.calls[0][0]).toEqual({key: '5', dropPosition: 'on', type: 'item'}); + expect(shouldAcceptItemDrop.mock.calls[1][0]).toEqual({key: '5', dropPosition: 'on', type: 'item'}); + expect(shouldAcceptItemDrop.mock.calls[2][0]).toEqual({key: '5', dropPosition: 'on', type: 'item'}); + expect(shouldAcceptItemDrop.mock.calls[2][1]).toEqual(new Set(['text/plain', 'folder'])); + expect(shouldAcceptItemDrop.mock.calls[3][0]).toEqual({key: '5', dropPosition: 'on', type: 'item'}); + expect(shouldAcceptItemDrop.mock.calls[3][1]).toEqual(new Set(['text/plain', 'file'])); + }); + it('should allow the user to specify what a valid drop target is via shouldAcceptItemDrop', function () { let {getAllByRole} = render( + ); let grids = getAllByRole('grid'); @@ -1288,9 +1348,10 @@ describe('ListView', function () { expect(onItemDrop).toHaveBeenLastCalledWith({ target: { key: '5', - dropPosition: 'on' + dropPosition: 'on', + type: 'item' }, - isInternalDrop: false, + isInternal: false, dropOperation: 'move', items: [ { @@ -1340,14 +1401,15 @@ describe('ListView', function () { x: 1, y: 1, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); expect(onInsert).toHaveBeenCalledTimes(1); expect(onInsert).toHaveBeenCalledWith({ dropOperation: 'move', target: { key: '1', - dropPosition: 'before' + dropPosition: 'before', + type: 'item' }, items: [ { @@ -1369,7 +1431,7 @@ describe('ListView', function () { it('should accept a drop that contains a mix of allowed and disallowed drag types (directories and file case)', function () { let {getAllByRole} = render( - + ); let grids = getAllByRole('grid'); @@ -1530,16 +1592,17 @@ describe('ListView', function () { x: 1, y: 185, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); expect(onInsert).toHaveBeenCalledTimes(0); // Only has the file in onItemDrop, the folder item should have been filtered out expect(onItemDrop).toHaveBeenCalledWith({ target: { key: '5', - dropPosition: 'on' + dropPosition: 'on', + type: 'item' }, - isInternalDrop: false, + isInternal: false, dropOperation: 'move', items: [ { @@ -1562,6 +1625,46 @@ describe('ListView', function () { name: 'Adobe Fresco' }); }); + + it('should use user provided getDropOperation to determine default drop operation if provided', function () { + // Take what ever drop operation is allowed except move + let getDropOperation = (_, __, allowedOperations) => allowedOperations.filter(op => op !== 'move')[0]; + let {getAllByRole} = render( + ['move', 'link']}} /> + ); + + let grids = getAllByRole('grid'); + expect(grids).toHaveLength(2); + + let dropTarget = within(grids[0]).getAllByRole('row')[0]; + let list2Rows = within(grids[1]).getAllByRole('row'); + dragBetweenLists(list2Rows, dropTarget); + + expect(onReorder).toHaveBeenCalledTimes(0); + expect(onItemDrop).toHaveBeenCalledTimes(0); + expect(onRootDrop).toHaveBeenCalledTimes(0); + expect(onInsert).toHaveBeenCalledTimes(1); + expect(onInsert).toHaveBeenCalledWith({ + dropOperation: 'link', + target: { + key: '1', + dropPosition: 'before', + type: 'item' + }, + items: [ + { + kind: 'text', + types: new Set(['text/plain', 'folder']), + getText: expect.any(Function) + }, + { + kind: 'text', + types: new Set(['text/plain', 'file']), + getText: expect.any(Function) + } + ] + }); + }); }); }); @@ -1613,7 +1716,7 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); }); @@ -1676,7 +1779,7 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); }); @@ -1714,7 +1817,7 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); }); @@ -1769,7 +1872,7 @@ describe('ListView', function () { let dndState = globalDndState; expect(dndState.dropCollectionRef.current).toBe(list); - // Canceling the drop operation should clear dropCollectionRef before onDragEnd fires, resulting in isInternalDrop = false + // Canceling the drop operation should clear dropCollectionRef before onDragEnd fires, resulting in isInternal = false fireEvent.keyDown(document.body, {key: 'Escape'}); fireEvent.keyUp(document.body, {key: 'Escape'}); dndState = globalDndState; @@ -1781,7 +1884,7 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'cancel', - isInternalDrop: false + isInternal: false }); }); @@ -1825,7 +1928,8 @@ describe('ListView', function () { dropOperation: 'move', target: { key: '7', - dropPosition: 'before' + dropPosition: 'before', + type: 'item' }, items: [ { @@ -1863,7 +1967,8 @@ describe('ListView', function () { expect(onReorder).toHaveBeenCalledWith({ target: { key: '3', - dropPosition: 'before' + dropPosition: 'before', + type: 'item' }, keys: new Set(['1']), dropOperation: 'move' @@ -1892,7 +1997,7 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); expect(onRootDrop).toHaveBeenCalledWith({ dropOperation: 'move', @@ -1938,15 +2043,16 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); expect(onInsert).toHaveBeenCalledTimes(0); expect(onItemDrop).toHaveBeenCalledWith({ target: { key: '7', - dropPosition: 'on' + dropPosition: 'on', + type: 'item' }, - isInternalDrop: false, + isInternal: false, dropOperation: 'move', items: [ { @@ -1983,15 +2089,16 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'move', - isInternalDrop: true + isInternal: true }); expect(onInsert).toHaveBeenCalledTimes(0); expect(onItemDrop).toHaveBeenLastCalledWith({ target: { key: '3', - dropPosition: 'on' + dropPosition: 'on', + type: 'item' }, - isInternalDrop: true, + isInternal: true, dropOperation: 'move', items: [ { @@ -2055,15 +2162,16 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); expect(onInsert).toHaveBeenCalledTimes(0); expect(onItemDrop).toHaveBeenCalledWith({ target: { key: '8', - dropPosition: 'on' + dropPosition: 'on', + type: 'item' }, - isInternalDrop: false, + isInternal: false, dropOperation: 'move', items: [ { @@ -2199,7 +2307,7 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); expect(onReorder).toHaveBeenCalledTimes(0); expect(onItemDrop).toHaveBeenCalledTimes(0); @@ -2209,7 +2317,8 @@ describe('ListView', function () { dropOperation: 'move', target: { key: '7', - dropPosition: 'before' + dropPosition: 'before', + type: 'item' }, items: [ @@ -2255,15 +2364,16 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'move', - isInternalDrop: false + isInternal: false }); expect(onInsert).toHaveBeenCalledTimes(0); expect(onItemDrop).toHaveBeenCalledWith({ target: { key: '7', - dropPosition: 'on' + dropPosition: 'on', + type: 'item' }, - isInternalDrop: false, + isInternal: false, dropOperation: 'move', items: [ { @@ -2280,6 +2390,44 @@ describe('ListView', function () { name: 'Adobe Photoshop' }); }); + + it('should use user provided getDropOperation to determine default drop operation if provided', function () { + // Take what ever drop operation is allowed except move + let getDropOperation = (_, __, allowedOperations) => allowedOperations.filter(op => op !== 'move')[0]; + let tree = render( + ['move', 'link']}} secondListDnDOptions={{...mockUtilityOptions, getDropOperation}} /> + ); + + beginDrag(tree); + // Move to 2nd list's first insert indicator + userEvent.tab(); + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + + expect(document.activeElement).toHaveAttribute('aria-label', 'Insert before Pictures'); + fireEvent.keyDown(document.activeElement, {key: 'Enter'}); + fireEvent.keyUp(document.activeElement, {key: 'Enter'}); + + expect(onReorder).toHaveBeenCalledTimes(0); + expect(onItemDrop).toHaveBeenCalledTimes(0); + expect(onRootDrop).toHaveBeenCalledTimes(0); + expect(onInsert).toHaveBeenCalledTimes(1); + expect(onInsert).toHaveBeenCalledWith({ + dropOperation: 'link', + target: { + key: '7', + dropPosition: 'before', + type: 'item' + }, + items: [ + { + kind: 'text', + types: new Set(['text/plain', 'file']), + getText: expect.any(Function) + } + ] + }); + }); }); it('should allow moving one item within a list', async function () { @@ -2749,7 +2897,7 @@ describe('ListView', function () { x: 50, y: 25, dropOperation: 'move', - isInternalDrop: true + isInternal: true }); onSelectionChange.mockClear(); onDragStart.mockClear(); diff --git a/packages/@react-stately/dnd/package.json b/packages/@react-stately/dnd/package.json index 61f558b40ce..1b62217f42c 100644 --- a/packages/@react-stately/dnd/package.json +++ b/packages/@react-stately/dnd/package.json @@ -19,7 +19,6 @@ "dependencies": { "@babel/runtime": "^7.6.2", "@react-stately/selection": "^3.10.3", - "@react-stately/utils": "^3.5.1", "@react-types/shared": "^3.14.1" }, "peerDependencies": { diff --git a/packages/@react-stately/dnd/src/useDraggableCollectionState.ts b/packages/@react-stately/dnd/src/useDraggableCollectionState.ts index 3c3ca285a22..d1fe152a7ab 100644 --- a/packages/@react-stately/dnd/src/useDraggableCollectionState.ts +++ b/packages/@react-stately/dnd/src/useDraggableCollectionState.ts @@ -102,14 +102,14 @@ export function useDraggableCollectionState(props: DraggableCollectionStateOptio }, endDrag(event) { let { - isInternalDrop + isInternal } = event; if (typeof onDragEnd === 'function') { onDragEnd({ ...event, keys: draggingKeys.current, - isInternalDrop + isInternal }); } diff --git a/packages/@react-stately/dnd/src/useDroppableCollectionState.ts b/packages/@react-stately/dnd/src/useDroppableCollectionState.ts index e8d407028e5..8f3aae1cda0 100644 --- a/packages/@react-stately/dnd/src/useDroppableCollectionState.ts +++ b/packages/@react-stately/dnd/src/useDroppableCollectionState.ts @@ -18,7 +18,7 @@ interface DropOperationEvent { target: DropTarget, types: DragTypes, allowedOperations: DropOperation[], - isInternalDrop: boolean, + isInternal: boolean, draggingKeys: Set } @@ -38,7 +38,7 @@ export interface DroppableCollectionState { export function useDroppableCollectionState(props: DroppableCollectionStateOptions): DroppableCollectionState { let { - acceptedDragTypes, + acceptedDragTypes = 'all', onInsert, onRootDrop, onItemDrop, @@ -68,25 +68,29 @@ export function useDroppableCollectionState(props: DroppableCollectionStateOptio target, types, allowedOperations, - isInternalDrop, + isInternal, draggingKeys } = e; if (acceptedDragTypes === 'all' || acceptedDragTypes.some(type => types.has(type))) { - let isValidInsert = onInsert && target.type === 'item' && !isInternalDrop && (target.dropPosition === 'before' || target.dropPosition === 'after'); - let isValidReorder = onReorder && target.type === 'item' && isInternalDrop && (target.dropPosition === 'before' || target.dropPosition === 'after'); + let isValidInsert = onInsert && target.type === 'item' && !isInternal && (target.dropPosition === 'before' || target.dropPosition === 'after'); + let isValidReorder = onReorder && target.type === 'item' && isInternal && (target.dropPosition === 'before' || target.dropPosition === 'after'); // Feedback was that internal root drop was weird so preventing that from happening - let isValidRootDrop = onRootDrop && target.type === 'root' && !isInternalDrop; + let isValidRootDrop = onRootDrop && target.type === 'root' && !isInternal; // Automatically prevent items (i.e. folders) from being dropped on themselves. - let isValidOnItemDrop = onItemDrop && target.type === 'item' && target.dropPosition === 'on' && !(isInternalDrop && draggingKeys.has(target.key)) && (!shouldAcceptItemDrop || shouldAcceptItemDrop(target, types)); + let isValidOnItemDrop = onItemDrop && target.type === 'item' && target.dropPosition === 'on' && !(isInternal && draggingKeys.has(target.key)) && (!shouldAcceptItemDrop || shouldAcceptItemDrop(target, types)); if (onDrop || isValidInsert || isValidReorder || isValidRootDrop || isValidOnItemDrop) { - return allowedOperations[0]; + if (getDropOperation) { + return getDropOperation(target, types, allowedOperations); + } else { + return allowedOperations[0]; + } } } return 'cancel'; - }, [acceptedDragTypes, onInsert, onRootDrop, onItemDrop, shouldAcceptItemDrop, onReorder, onDrop]); + }, [acceptedDragTypes, getDropOperation, onInsert, onRootDrop, onItemDrop, shouldAcceptItemDrop, onReorder, onDrop]); return { collection, @@ -141,10 +145,7 @@ export function useDroppableCollectionState(props: DroppableCollectionStateOptio return false; }, getDropOperation(e) { - let {target, types, allowedOperations} = e; - return typeof getDropOperation === 'function' - ? getDropOperation(target, types, allowedOperations) - : defaultGetDropOperation(e); + return defaultGetDropOperation(e); } }; } diff --git a/packages/@react-types/list/package.json b/packages/@react-types/list/package.json index 05beaba2e10..fe838c7009c 100644 --- a/packages/@react-types/list/package.json +++ b/packages/@react-types/list/package.json @@ -9,15 +9,12 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-spectrum/dnd": "3.0.0-alpha.6", - "@react-types/shared": "^3.14.1" + "@react-aria/gridlist": "^3.0.0", + "@react-spectrum/list": "^3.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" }, - "devDependencies": { - "@react-spectrum/dnd": "3.0.0-alpha.6" - }, "publishConfig": { "access": "public" } diff --git a/packages/@react-types/list/src/index.d.ts b/packages/@react-types/list/src/index.d.ts index 12aaa1e5dff..361a05152a0 100644 --- a/packages/@react-types/list/src/index.d.ts +++ b/packages/@react-types/list/src/index.d.ts @@ -10,57 +10,5 @@ * governing permissions and limitations under the License. */ -import { - AriaLabelingProps, - AsyncLoadable, - CollectionBase, - DisabledBehavior, - DOMProps, - LoadingState, - MultipleSelection, - SpectrumSelectionProps, - StyleProps -} from '@react-types/shared'; -import type {DnDHooks} from '@react-spectrum/dnd'; -import {Key} from 'react'; - -export interface GridListProps extends CollectionBase, MultipleSelection { - /** - * Handler that is called when a user performs an action on an item. The exact user event depends on - * the collection's `selectionBehavior` prop and the interaction modality. - */ - onAction?: (key: Key) => void, - /** Whether `disabledKeys` applies to all interactions, or only selection. */ - disabledBehavior?: DisabledBehavior -} - -export interface AriaGridListProps extends GridListProps, DOMProps, AriaLabelingProps {} - -export interface SpectrumListViewProps extends AriaGridListProps, StyleProps, SpectrumSelectionProps, Omit { - /** - * Sets the amount of vertical padding within each cell. - * @default 'regular' - */ - density?: 'compact' | 'regular' | 'spacious', - /** Whether the ListView should be displayed with a quiet style. */ - isQuiet?: boolean, - /** The current loading state of the ListView. Determines whether or not the progress circle should be shown. */ - loadingState?: LoadingState, - /** - * Sets the text behavior for the row contents. - * @default 'truncate' - */ - overflowMode?: 'truncate' | 'wrap', - /** Sets what the ListView should render when there is no content to display. */ - renderEmptyState?: () => JSX.Element, - /** - * Handler that is called when a user performs an action on an item. The exact user event depends on - * the collection's `selectionStyle` prop and the interaction modality. - */ - onAction?: (key: Key) => void, - /** - * The drag and drop hooks returned by `useDnDHooks` used to enable drag and drop behavior for the ListView. - * @private - */ - dndHooks?: DnDHooks['dndHooks'] -} +export type {AriaGridListProps, GridListProps} from '@react-aria/gridlist'; +export type {SpectrumListViewProps} from '@react-spectrum/list'; diff --git a/packages/@react-types/shared/src/dnd.d.ts b/packages/@react-types/shared/src/dnd.d.ts index e552cbb0c91..b5c5fe28400 100644 --- a/packages/@react-types/shared/src/dnd.d.ts +++ b/packages/@react-types/shared/src/dnd.d.ts @@ -117,10 +117,7 @@ interface DroppableCollectionDropEvent extends DropEvent { interface DroppableCollectionInsertDropEvent { items: DropItem[], dropOperation: DropOperation, - target: { - key: Key, - dropPosition: Omit - } + target: ItemDropTarget } interface DroppableCollectionRootDropEvent { @@ -131,24 +128,18 @@ interface DroppableCollectionRootDropEvent { interface DroppableCollectionOnItemDropEvent { items: DropItem[], dropOperation: DropOperation, - isInternalDrop: boolean, - target: { - key: Key, - dropPosition: 'on' - } + isInternal: boolean, + target: ItemDropTarget } interface DroppableCollectionReorderEvent { keys: Set, dropOperation: DropOperation, - target: { - key: Key, - dropPosition: Omit - } + target: ItemDropTarget } export interface DragTypes { - has(type: string): boolean + has(type: string | symbol): boolean } export interface DropTargetDelegate { @@ -193,9 +184,10 @@ export interface DroppableCollectionProps { */ onReorder?: (e: DroppableCollectionReorderEvent) => void, /** - * The drag types that the droppable collection accepts. If your collection accepts directories, include 'directory' in your array of allowed types. + * The drag types that the droppable collection accepts. If directories are accepted, include the DIRECTORY_DRAG_TYPE from @react-aria/dnd in the array of allowed types. + * @default 'all' */ - acceptedDragTypes?: 'all' | Array, + acceptedDragTypes?: 'all' | Array, /** * A function returning whether a given target in the droppable collection is a valid "on" drop target for the current drag types. */ @@ -212,7 +204,7 @@ interface DraggableCollectionMoveEvent extends DragMoveEvent { interface DraggableCollectionEndEvent extends DragEndEvent { keys: Set, - isInternalDrop: boolean + isInternal: boolean } export type DragPreviewRenderer = (items: DragItem[], callback: (node: HTMLElement) => void) => void;