@react-aria/dnd
Advanced tools
Comparing version 3.0.0-nightly.3575 to 3.0.0-nightly-4980928d3-240906
@@ -1,6 +0,12 @@ | ||
import React, { HTMLAttributes, RefObject, Key } from "react"; | ||
import { DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropMoveEvent, DropOperation, DragTypes, DroppableCollectionProps, DropTargetDelegate, KeyboardDelegate, DropTarget, DragEndEvent, DragItem, DragMoveEvent, DragPreviewRenderer, DragStartEvent, DOMAttributes, DropItem, Collection, Node } from "@react-types/shared"; | ||
import { DirectoryDropItem, DropItem, FileDropItem, TextDropItem, DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropMoveEvent, DropOperation, DragTypes, RefObject, DroppableCollectionProps, DropTargetDelegate, KeyboardDelegate, DropTarget, DragEndEvent, DragItem, DragMoveEvent, DragPreviewRenderer, DragStartEvent, Key, DOMAttributes, Direction, Node, Orientation } from "@react-types/shared"; | ||
import { AriaButtonProps } from "@react-types/button"; | ||
import React, { HTMLAttributes, JSX } from "react"; | ||
import { DroppableCollectionState, DraggableCollectionState } from "@react-stately/dnd"; | ||
import { AriaButtonProps } from "@react-types/button"; | ||
export const DIRECTORY_DRAG_TYPE: unique symbol; | ||
/** Returns whether a drop item contains text data. */ | ||
export function isTextDropItem(dropItem: DropItem): dropItem is TextDropItem; | ||
/** Returns whether a drop item is a file. */ | ||
export function isFileDropItem(dropItem: DropItem): dropItem is FileDropItem; | ||
/** Returns whether a drop item is a directory. */ | ||
export function isDirectoryDropItem(dropItem: DropItem): dropItem is DirectoryDropItem; | ||
/** @private */ | ||
@@ -10,3 +16,3 @@ export function isVirtualDragging(): boolean; | ||
/** A ref for the droppable element. */ | ||
ref: RefObject<HTMLElement>; | ||
ref: RefObject<HTMLElement | null>; | ||
/** | ||
@@ -33,2 +39,11 @@ * A function returning the drop operation to be performed when items matching the given types are dropped | ||
onDrop?: (e: DropEvent) => void; | ||
/** | ||
* Whether the item has an explicit focusable drop affordance to initiate accessible drag and drop mode. | ||
* If true, the dropProps will omit these event handlers, and they will be applied to dropButtonProps instead. | ||
*/ | ||
hasDropButton?: boolean; | ||
/** | ||
* Whether the drop target is disabled. If true, the drop target will not accept any drops. | ||
*/ | ||
isDisabled?: boolean; | ||
} | ||
@@ -40,2 +55,4 @@ export interface DropResult { | ||
isDropTarget: boolean; | ||
/** Props for the explicit drop button affordance, if any. */ | ||
dropButtonProps?: AriaButtonProps; | ||
} | ||
@@ -61,3 +78,3 @@ /** | ||
*/ | ||
export function useDroppableCollection(props: DroppableCollectionOptions, state: DroppableCollectionState, ref: RefObject<HTMLElement>): DroppableCollectionResult; | ||
export function useDroppableCollection(props: DroppableCollectionOptions, state: DroppableCollectionState, ref: RefObject<HTMLElement | null>): DroppableCollectionResult; | ||
export interface DroppableItemOptions { | ||
@@ -76,3 +93,3 @@ /** The drop target represented by the item. */ | ||
*/ | ||
export function useDroppableItem(options: DroppableItemOptions, state: DroppableCollectionState, ref: RefObject<HTMLElement>): DroppableItemResult; | ||
export function useDroppableItem(options: DroppableItemOptions, state: DroppableCollectionState, ref: RefObject<HTMLElement | null>): DroppableItemResult; | ||
export interface DropIndicatorProps { | ||
@@ -96,3 +113,3 @@ /** The drop target that the drop indicator represents. */ | ||
*/ | ||
export function useDropIndicator(props: DropIndicatorProps, state: DroppableCollectionState, ref: RefObject<HTMLElement>): DropIndicatorAria; | ||
export function useDropIndicator(props: DropIndicatorProps, state: DroppableCollectionState, ref: RefObject<HTMLElement | null>): DropIndicatorAria; | ||
export interface DragOptions { | ||
@@ -108,3 +125,3 @@ /** Handler that is called when a drag operation is started. */ | ||
/** The ref of the element that will be rendered as the drag preview while dragging. */ | ||
preview?: RefObject<DragPreviewRenderer>; | ||
preview?: RefObject<DragPreviewRenderer | null>; | ||
/** Function that returns the drop operations that are allowed for the dragged items. If not provided, all drop operations are allowed. */ | ||
@@ -117,2 +134,6 @@ getAllowedDropOperations?: () => DropOperation[]; | ||
hasDragButton?: boolean; | ||
/** | ||
* Whether the drag operation is disabled. If true, the element will not be draggable. | ||
*/ | ||
isDisabled?: boolean; | ||
} | ||
@@ -164,3 +185,3 @@ export interface DragResult { | ||
*/ | ||
export function useDraggableCollection(props: DraggableCollectionOptions, state: DraggableCollectionState, ref: RefObject<HTMLElement>): void; | ||
export function useDraggableCollection(props: DraggableCollectionOptions, state: DraggableCollectionState, ref: RefObject<HTMLElement | null>): void; | ||
export interface DragPreviewProps { | ||
@@ -179,2 +200,4 @@ children: (items: DragItem[]) => JSX.Element; | ||
onPaste?: (items: DropItem[]) => void; | ||
/** Whether the clipboard is disabled. */ | ||
isDisabled?: boolean; | ||
} | ||
@@ -190,4 +213,22 @@ export interface ClipboardResult { | ||
export function useClipboard(options: ClipboardProps): ClipboardResult; | ||
interface ListDropTargetDelegateOptions { | ||
/** | ||
* Whether the items are arranged in a stack or grid. | ||
* @default 'stack' | ||
*/ | ||
layout?: 'stack' | 'grid'; | ||
/** | ||
* The primary orientation of the items. Usually this is the | ||
* direction that the collection scrolls. | ||
* @default 'vertical' | ||
*/ | ||
orientation?: Orientation; | ||
/** | ||
* The horizontal layout direction. | ||
* @default 'ltr' | ||
*/ | ||
direction?: Direction; | ||
} | ||
export class ListDropTargetDelegate implements DropTargetDelegate { | ||
constructor(collection: Collection<Node<unknown>>, ref: RefObject<HTMLElement>); | ||
constructor(collection: Iterable<Node<unknown>>, ref: RefObject<HTMLElement | null>, options?: ListDropTargetDelegateOptions); | ||
getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget; | ||
@@ -194,0 +235,0 @@ } |
{ | ||
"name": "@react-aria/dnd", | ||
"version": "3.0.0-nightly.3575+a13802d8b", | ||
"version": "3.0.0-nightly-4980928d3-240906", | ||
"description": "Spectrum UI components in React", | ||
@@ -8,2 +8,7 @@ "license": "Apache-2.0", | ||
"module": "dist/module.js", | ||
"exports": { | ||
"types": "./dist/types.d.ts", | ||
"import": "./dist/import.mjs", | ||
"require": "./dist/main.js" | ||
}, | ||
"types": "dist/types.d.ts", | ||
@@ -21,17 +26,16 @@ "source": "src/index.ts", | ||
"dependencies": { | ||
"@babel/runtime": "^7.6.2", | ||
"@internationalized/string": "3.0.1-nightly.3575+a13802d8b", | ||
"@react-aria/i18n": "3.0.0-nightly.1875+a13802d8b", | ||
"@react-aria/interactions": "3.0.0-nightly.1875+a13802d8b", | ||
"@react-aria/live-announcer": "3.0.0-nightly.1875+a13802d8b", | ||
"@react-aria/overlays": "3.0.0-nightly.1875+a13802d8b", | ||
"@react-aria/utils": "3.0.0-nightly.1875+a13802d8b", | ||
"@react-aria/visually-hidden": "3.0.0-nightly.1875+a13802d8b", | ||
"@react-stately/dnd": "3.0.0-nightly.3575+a13802d8b", | ||
"@react-types/button": "3.6.3-nightly.3575+a13802d8b", | ||
"@react-types/shared": "3.0.0-nightly.1875+a13802d8b" | ||
"@internationalized/string": "^3.0.0-nightly-4980928d3-240906", | ||
"@react-aria/i18n": "^3.0.0-nightly-4980928d3-240906", | ||
"@react-aria/interactions": "^3.0.0-nightly-4980928d3-240906", | ||
"@react-aria/live-announcer": "^3.0.0-nightly-4980928d3-240906", | ||
"@react-aria/overlays": "^3.0.0-nightly-4980928d3-240906", | ||
"@react-aria/utils": "^3.0.0-nightly-4980928d3-240906", | ||
"@react-stately/dnd": "^3.0.0-nightly-4980928d3-240906", | ||
"@react-types/button": "^3.0.0-nightly-4980928d3-240906", | ||
"@react-types/shared": "^3.0.0-nightly-4980928d3-240906", | ||
"@swc/helpers": "^0.5.0" | ||
}, | ||
"peerDependencies": { | ||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", | ||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" | ||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0", | ||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" | ||
}, | ||
@@ -41,3 +45,3 @@ "publishConfig": { | ||
}, | ||
"gitHead": "a13802d8be6f83af1450e56f7a88527b10d9cadf" | ||
} | ||
"stableVersion": "3.7.2" | ||
} |
@@ -16,3 +16,2 @@ /* | ||
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} from './utils'; | ||
@@ -30,2 +29,3 @@ import {isVirtualClick, isVirtualPointerEvent} from '@react-aria/utils'; | ||
element: FocusableElement, | ||
preventFocusOnDrop?: boolean, | ||
getDropOperation?: (types: Set<string>, allowedOperations: DropOperation[]) => DropOperation, | ||
@@ -263,3 +263,6 @@ onDropEnter?: (e: DropEnterEvent, dragTarget: DragTarget) => void, | ||
let dropTarget = this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement)); | ||
let dropTarget = | ||
this.validDropTargets.find(target => target.element === e.target as HTMLElement) || | ||
this.validDropTargets.find(target => target.element.contains(e.target as HTMLElement)); | ||
if (!dropTarget) { | ||
@@ -484,3 +487,2 @@ if (this.currentDropTarget) { | ||
if (item !== this.currentDropItem) { | ||
@@ -494,3 +496,3 @@ if (item && typeof this.currentDropTarget.onDropTargetEnter === 'function') { | ||
// Annouce first drop target after drag start announcement finishes. | ||
// Announce first drop target after drag start announcement finishes. | ||
// Otherwise, it will never get announced because drag start announcement is assertive. | ||
@@ -506,2 +508,3 @@ if (!this.initialFocused) { | ||
this.teardown(); | ||
endDragging(); | ||
@@ -519,22 +522,14 @@ if (typeof this.dragTarget.onDragEnd === 'function') { | ||
// Blur and re-focus the drop target so that the focus ring appears. | ||
if (this.currentDropTarget) { | ||
// Since we cancel all focus events in drag sessions, refire blur to make sure state gets updated so drag target doesn't think it's still focused | ||
// i.e. When you from one list to another during a drag session, we need the blur to fire on the first list after the drag. | ||
if (!this.dragTarget.element.contains(this.currentDropTarget.element)) { | ||
this.dragTarget.element.dispatchEvent(new FocusEvent('blur')); | ||
this.dragTarget.element.dispatchEvent(new FocusEvent('focusout', {bubbles: true})); | ||
} | ||
// Re-focus the focusedKey upon reorder. This requires a React rerender between blurring and focusing. | ||
flushSync(() => { | ||
this.currentDropTarget.element.blur(); | ||
}); | ||
this.currentDropTarget.element.focus(); | ||
if (this.currentDropTarget && !this.currentDropTarget.preventFocusOnDrop) { | ||
// Re-trigger focus event on active element, since it will not have received it during dragging (see cancelEvent). | ||
// This corrects state such as whether focus ring should appear. | ||
// useDroppableCollection handles this itself, so this is only for standalone drop zones. | ||
document.activeElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); | ||
} | ||
this.setCurrentDropTarget(null); | ||
endDragging(); | ||
} | ||
cancel() { | ||
this.setCurrentDropTarget(null); | ||
this.end(); | ||
@@ -541,0 +536,0 @@ if (!this.dragTarget.element.closest('[aria-hidden="true"]')) { |
@@ -68,1 +68,2 @@ /* | ||
export {isVirtualDragging} from './DragManager'; | ||
export {isDirectoryDropItem, isFileDropItem, isTextDropItem} from './utils'; |
@@ -1,15 +0,76 @@ | ||
import {Collection, DropTarget, DropTargetDelegate, Node} from '@react-types/shared'; | ||
import {RefObject} from 'react'; | ||
import {Direction, DropTarget, DropTargetDelegate, Node, Orientation, RefObject} from '@react-types/shared'; | ||
interface ListDropTargetDelegateOptions { | ||
/** | ||
* Whether the items are arranged in a stack or grid. | ||
* @default 'stack' | ||
*/ | ||
layout?: 'stack' | 'grid', | ||
/** | ||
* The primary orientation of the items. Usually this is the | ||
* direction that the collection scrolls. | ||
* @default 'vertical' | ||
*/ | ||
orientation?: Orientation, | ||
/** | ||
* The horizontal layout direction. | ||
* @default 'ltr' | ||
*/ | ||
direction?: Direction | ||
} | ||
// Terms used in the below code: | ||
// * "Primary" – The main layout direction. For stacks, this is the direction | ||
// that the stack is arranged in (e.g. horizontal or vertical). | ||
// For grids, this is the main scroll direction. | ||
// * "Secondary" – The secondary layout direction. For stacks, there is no secondary | ||
// layout direction. For grids, this is the opposite of the primary direction. | ||
// * "Flow" – The flow direction of the items. For stacks, this is the the primary | ||
// direction. For grids, it is the secondary direction. | ||
export class ListDropTargetDelegate implements DropTargetDelegate { | ||
private collection: Collection<Node<unknown>>; | ||
private ref: RefObject<HTMLElement>; | ||
private collection: Iterable<Node<unknown>>; | ||
private ref: RefObject<HTMLElement | null>; | ||
private layout: 'stack' | 'grid'; | ||
private orientation: Orientation; | ||
private direction: Direction; | ||
constructor(collection: Collection<Node<unknown>>, ref: RefObject<HTMLElement>) { | ||
constructor(collection: Iterable<Node<unknown>>, ref: RefObject<HTMLElement | null>, options?: ListDropTargetDelegateOptions) { | ||
this.collection = collection; | ||
this.ref = ref; | ||
this.layout = options?.layout || 'stack'; | ||
this.orientation = options?.orientation || 'vertical'; | ||
this.direction = options?.direction || 'ltr'; | ||
} | ||
private getPrimaryStart(rect: DOMRect) { | ||
return this.orientation === 'horizontal' ? rect.left : rect.top; | ||
} | ||
private getPrimaryEnd(rect: DOMRect) { | ||
return this.orientation === 'horizontal' ? rect.right : rect.bottom; | ||
} | ||
private getSecondaryStart(rect: DOMRect) { | ||
return this.orientation === 'horizontal' ? rect.top : rect.left; | ||
} | ||
private getSecondaryEnd(rect: DOMRect) { | ||
return this.orientation === 'horizontal' ? rect.bottom : rect.right; | ||
} | ||
private getFlowStart(rect: DOMRect) { | ||
return this.layout === 'stack' ? this.getPrimaryStart(rect) : this.getSecondaryStart(rect); | ||
} | ||
private getFlowEnd(rect: DOMRect) { | ||
return this.layout === 'stack' ? this.getPrimaryEnd(rect) : this.getSecondaryEnd(rect); | ||
} | ||
private getFlowSize(rect: DOMRect) { | ||
return this.getFlowEnd(rect) - this.getFlowStart(rect); | ||
} | ||
getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget { | ||
if (this.collection.size === 0) { | ||
if (this.collection[Symbol.iterator]().next().done) { | ||
return {type: 'root'}; | ||
@@ -19,5 +80,12 @@ } | ||
let rect = this.ref.current.getBoundingClientRect(); | ||
x += rect.x; | ||
y += rect.y; | ||
let primary = this.orientation === 'horizontal' ? x : y; | ||
let secondary = this.orientation === 'horizontal' ? y : x; | ||
primary += this.getPrimaryStart(rect); | ||
secondary += this.getSecondaryStart(rect); | ||
let flow = this.layout === 'stack' ? primary : secondary; | ||
let isPrimaryRTL = this.orientation === 'horizontal' && this.direction === 'rtl'; | ||
let isSecondaryRTL = this.layout === 'grid' && this.orientation === 'vertical' && this.direction === 'rtl'; | ||
let isFlowRTL = this.layout === 'stack' ? isPrimaryRTL : isSecondaryRTL; | ||
let elements = this.ref.current.querySelectorAll('[data-key]'); | ||
@@ -31,3 +99,7 @@ let elementMap = new Map<string, HTMLElement>(); | ||
let items = [...this.collection]; | ||
// TODO: assume that only item type items are valid drop targets. This is to prevent a crash when dragging over the loader | ||
// row since it doesn't have a data-key set on it. Will eventually need to handle the case with drag and drop and loaders located between rows aka tree. | ||
// Can see https://github.com/adobe/react-spectrum/pull/4210/files#diff-21e555e0c597a28215e36137f5be076a65a1e1456c92cd0fdd60f866929aae2a for additional logic | ||
// that may need to happen then | ||
let items = [...this.collection].filter(item => item.type === 'item'); | ||
let low = 0; | ||
@@ -40,7 +112,18 @@ let high = items.length; | ||
let rect = element.getBoundingClientRect(); | ||
let update = (isGreater: boolean) => { | ||
if (isGreater) { | ||
low = mid + 1; | ||
} else { | ||
high = mid; | ||
} | ||
}; | ||
if (y < rect.top) { | ||
high = mid; | ||
} else if (y > rect.bottom) { | ||
low = mid + 1; | ||
if (primary < this.getPrimaryStart(rect)) { | ||
update(isPrimaryRTL); | ||
} else if (primary > this.getPrimaryEnd(rect)) { | ||
update(!isPrimaryRTL); | ||
} else if (secondary < this.getSecondaryStart(rect)) { | ||
update(isSecondaryRTL); | ||
} else if (secondary > this.getSecondaryEnd(rect)) { | ||
update(!isSecondaryRTL); | ||
} else { | ||
@@ -55,15 +138,15 @@ let target: DropTarget = { | ||
// Otherwise, if dropping on the item is accepted, try the before/after positions if within 5px | ||
// of the top or bottom of the item. | ||
if (y <= rect.top + 5 && isValidDropTarget({...target, dropPosition: 'before'})) { | ||
target.dropPosition = 'before'; | ||
} else if (y >= rect.bottom - 5 && isValidDropTarget({...target, dropPosition: 'after'})) { | ||
target.dropPosition = 'after'; | ||
// of the start or end of the item. | ||
if (flow <= this.getFlowStart(rect) + 5 && isValidDropTarget({...target, dropPosition: 'before'})) { | ||
target.dropPosition = isFlowRTL ? 'after' : 'before'; | ||
} else if (flow >= this.getFlowEnd(rect) - 5 && isValidDropTarget({...target, dropPosition: 'after'})) { | ||
target.dropPosition = isFlowRTL ? 'before' : 'after'; | ||
} | ||
} else { | ||
// If dropping on the item isn't accepted, try the target before or after depending on the y position. | ||
let midY = rect.top + rect.height / 2; | ||
if (y <= midY && isValidDropTarget({...target, dropPosition: 'before'})) { | ||
target.dropPosition = 'before'; | ||
} else if (y >= midY && isValidDropTarget({...target, dropPosition: 'after'})) { | ||
target.dropPosition = 'after'; | ||
// If dropping on the item isn't accepted, try the target before or after depending on the position. | ||
let mid = this.getFlowStart(rect) + this.getFlowSize(rect) / 2; | ||
if (flow <= mid && isValidDropTarget({...target, dropPosition: 'before'})) { | ||
target.dropPosition = isFlowRTL ? 'after' : 'before'; | ||
} else if (flow >= mid && isValidDropTarget({...target, dropPosition: 'after'})) { | ||
target.dropPosition = isFlowRTL ? 'before' : 'after'; | ||
} | ||
@@ -80,7 +163,7 @@ } | ||
if (Math.abs(y - rect.top) < Math.abs(y - rect.bottom)) { | ||
if (primary < this.getPrimaryStart(rect) || Math.abs(flow - this.getFlowStart(rect)) < Math.abs(flow - this.getFlowEnd(rect))) { | ||
return { | ||
type: 'item', | ||
key: item.key, | ||
dropPosition: 'before' | ||
dropPosition: isFlowRTL ? 'after' : 'before' | ||
}; | ||
@@ -92,5 +175,5 @@ } | ||
key: item.key, | ||
dropPosition: 'after' | ||
dropPosition: isFlowRTL ? 'before' : 'after' | ||
}; | ||
} | ||
} |
@@ -13,12 +13,18 @@ /* | ||
import {getScrollParent, isIOS, isWebKit} from '@react-aria/utils'; | ||
import {RefObject, useCallback, useEffect, useRef} from 'react'; | ||
import {getScrollParent, isIOS, isScrollable, isWebKit} from '@react-aria/utils'; | ||
import {RefObject} from '@react-types/shared'; | ||
import {useCallback, useEffect, useRef} from 'react'; | ||
const AUTOSCROLL_AREA_SIZE = 20; | ||
export function useAutoScroll(ref: RefObject<Element>) { | ||
export function useAutoScroll(ref: RefObject<Element | null>) { | ||
let scrollableRef = useRef<Element>(null); | ||
let scrollableX = useRef(true); | ||
let scrollableY = useRef(true); | ||
useEffect(() => { | ||
if (ref.current) { | ||
scrollableRef.current = getScrollParent(ref.current); | ||
scrollableRef.current = isScrollable(ref.current) ? ref.current : getScrollParent(ref.current); | ||
let style = window.getComputedStyle(scrollableRef.current); | ||
scrollableX.current = /(auto|scroll)/.test(style.overflowX); | ||
scrollableY.current = /(auto|scroll)/.test(style.overflowY); | ||
} | ||
@@ -33,5 +39,19 @@ }, [ref]); | ||
useEffect(() => { | ||
return () => { | ||
if (state.timer) { | ||
cancelAnimationFrame(state.timer); | ||
state.timer = null; | ||
} | ||
}; | ||
// state will become a new object, so it's ok to use in the dependency array for unmount | ||
}, [state]); | ||
let scroll = useCallback(() => { | ||
scrollableRef.current.scrollLeft += state.dx; | ||
scrollableRef.current.scrollTop += state.dy; | ||
if (scrollableX.current) { | ||
scrollableRef.current.scrollLeft += state.dx; | ||
} | ||
if (scrollableY.current) { | ||
scrollableRef.current.scrollTop += state.dy; | ||
} | ||
@@ -38,0 +58,0 @@ if (state.timer) { |
@@ -13,3 +13,3 @@ /* | ||
import {chain} from '@react-aria/utils'; | ||
import {chain, useEffectEvent} from '@react-aria/utils'; | ||
import {DOMAttributes, DragItem, DropItem} from '@react-types/shared'; | ||
@@ -28,3 +28,5 @@ import {readFromDataTransfer, writeToDataTransfer} from './utils'; | ||
/** Handler that is called when the user triggers a paste interaction. */ | ||
onPaste?: (items: DropItem[]) => void | ||
onPaste?: (items: DropItem[]) => void, | ||
/** Whether the clipboard is disabled. */ | ||
isDisabled?: boolean | ||
} | ||
@@ -69,5 +71,3 @@ | ||
export function useClipboard(options: ClipboardProps): ClipboardResult { | ||
let ref = useRef(options); | ||
ref.current = options; | ||
let {isDisabled} = options; | ||
let isFocusedRef = useRef(false); | ||
@@ -80,60 +80,57 @@ let {focusProps} = useFocus({ | ||
useEffect(() => { | ||
let onBeforeCopy = (e: ClipboardEvent) => { | ||
// Enable the "Copy" menu item in Safari if this element is focused and copying is supported. | ||
let options = ref.current; | ||
if (isFocusedRef.current && options.getItems) { | ||
e.preventDefault(); | ||
} | ||
}; | ||
let onBeforeCopy = useEffectEvent((e: ClipboardEvent) => { | ||
// Enable the "Copy" menu item in Safari if this element is focused and copying is supported. | ||
if (isFocusedRef.current && options.getItems) { | ||
e.preventDefault(); | ||
} | ||
}); | ||
let onCopy = (e: ClipboardEvent) => { | ||
let options = ref.current; | ||
if (!isFocusedRef.current || !options.getItems) { | ||
return; | ||
} | ||
let onCopy = useEffectEvent((e: ClipboardEvent) => { | ||
if (!isFocusedRef.current || !options.getItems) { | ||
return; | ||
} | ||
e.preventDefault(); | ||
writeToDataTransfer(e.clipboardData, options.getItems()); | ||
options.onCopy?.(); | ||
}); | ||
let onBeforeCut = useEffectEvent((e: ClipboardEvent) => { | ||
if (isFocusedRef.current && options.onCut && options.getItems) { | ||
e.preventDefault(); | ||
writeToDataTransfer(e.clipboardData, options.getItems()); | ||
options.onCopy?.(); | ||
}; | ||
} | ||
}); | ||
let onBeforeCut = (e: ClipboardEvent) => { | ||
let options = ref.current; | ||
if (isFocusedRef.current && options.onCut && options.getItems) { | ||
e.preventDefault(); | ||
} | ||
}; | ||
let onCut = useEffectEvent((e: ClipboardEvent) => { | ||
if (!isFocusedRef.current || !options.onCut || !options.getItems) { | ||
return; | ||
} | ||
let onCut = (e: ClipboardEvent) => { | ||
let options = ref.current; | ||
if (!isFocusedRef.current || !options.onCut || !options.getItems) { | ||
return; | ||
} | ||
e.preventDefault(); | ||
writeToDataTransfer(e.clipboardData, options.getItems()); | ||
options.onCut(); | ||
}); | ||
let onBeforePaste = useEffectEvent((e: ClipboardEvent) => { | ||
// Unfortunately, e.clipboardData.types is not available in this event so we always | ||
// have to enable the Paste menu item even if the type of data is unsupported. | ||
if (isFocusedRef.current && options.onPaste) { | ||
e.preventDefault(); | ||
writeToDataTransfer(e.clipboardData, options.getItems()); | ||
options.onCut(); | ||
}; | ||
} | ||
}); | ||
let onBeforePaste = (e: ClipboardEvent) => { | ||
let options = ref.current; | ||
// Unfortunately, e.clipboardData.types is not available in this event so we always | ||
// have to enable the Paste menu item even if the type of data is unsupported. | ||
if (isFocusedRef.current && options.onPaste) { | ||
e.preventDefault(); | ||
} | ||
}; | ||
let onPaste = useEffectEvent((e: ClipboardEvent) => { | ||
if (!isFocusedRef.current || !options.onPaste) { | ||
return; | ||
} | ||
let onPaste = (e: ClipboardEvent) => { | ||
let options = ref.current; | ||
if (!isFocusedRef.current || !options.onPaste) { | ||
return; | ||
} | ||
e.preventDefault(); | ||
let items = readFromDataTransfer(e.clipboardData); | ||
options.onPaste(items); | ||
}); | ||
e.preventDefault(); | ||
let items = readFromDataTransfer(e.clipboardData); | ||
options.onPaste(items); | ||
}; | ||
useEffect(() => { | ||
if (isDisabled) { | ||
return; | ||
} | ||
return chain( | ||
@@ -147,3 +144,3 @@ addGlobalEventListener('beforecopy', onBeforeCopy), | ||
); | ||
}, []); | ||
}, [isDisabled, onBeforeCopy, onCopy, onBeforeCut, onCut, onBeforePaste, onPaste]); | ||
@@ -150,0 +147,0 @@ return { |
@@ -14,4 +14,4 @@ /* | ||
import {AriaButtonProps} from '@react-types/button'; | ||
import {DragEndEvent, DragItem, DragMoveEvent, DragPreviewRenderer, DragStartEvent, DropOperation, PressEvent} from '@react-types/shared'; | ||
import {DragEvent, HTMLAttributes, RefObject, useRef, useState} from 'react'; | ||
import {DragEndEvent, DragItem, DragMoveEvent, DragPreviewRenderer, DragStartEvent, DropOperation, PressEvent, RefObject} from '@react-types/shared'; | ||
import {DragEvent, HTMLAttributes, useRef, useState} from 'react'; | ||
import * as DragManager from './DragManager'; | ||
@@ -35,3 +35,3 @@ import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, EFFECT_ALLOWED} from './constants'; | ||
/** The ref of the element that will be rendered as the drag preview while dragging. */ | ||
preview?: RefObject<DragPreviewRenderer>, | ||
preview?: RefObject<DragPreviewRenderer | null>, | ||
/** Function that returns the drop operations that are allowed for the dragged items. If not provided, all drop operations are allowed. */ | ||
@@ -43,3 +43,7 @@ getAllowedDropOperations?: () => DropOperation[], | ||
*/ | ||
hasDragButton?: boolean | ||
hasDragButton?: boolean, | ||
/** | ||
* Whether the drag operation is disabled. If true, the element will not be draggable. | ||
*/ | ||
isDisabled?: boolean | ||
} | ||
@@ -76,4 +80,4 @@ | ||
export function useDrag(options: DragOptions): DragResult { | ||
let {hasDragButton} = options; | ||
let stringFormatter = useLocalizedStringFormatter(intlMessages); | ||
let {hasDragButton, isDisabled} = options; | ||
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/dnd'); | ||
let state = useRef({ | ||
@@ -86,3 +90,3 @@ options, | ||
let isDraggingRef = useRef(false); | ||
let [, setDraggingState] = useState(false); | ||
let [isDragging, setDraggingState] = useState(false); | ||
let setDragging = (isDragging) => { | ||
@@ -100,2 +104,5 @@ isDraggingRef.current = isDragging; | ||
// Prevent the drag event from propagating to any parent draggables | ||
e.stopPropagation(); | ||
// If this drag was initiated by a mobile screen reader (e.g. VoiceOver or TalkBack), enter virtual dragging mode. | ||
@@ -149,3 +156,3 @@ if (modalityOnPointerDown.current === 'virtual') { | ||
// Rounding height to an even number prevents blurry preview seen on some screens | ||
let height = 2 * Math.round(rect.height / 2); | ||
let height = 2 * Math.round(size.height / 2); | ||
node.style.height = `${height}px`; | ||
@@ -159,9 +166,6 @@ | ||
addGlobalListener(window, 'drop', e => { | ||
if (!DragManager.isValidDropTarget(e.target as Element)) { | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
throw new Error('Drags initiated from the React Aria useDrag hook may only be dropped on a target created with useDrop. This ensures that a keyboard and screen reader accessible alternative is available.'); | ||
} | ||
}, {capture: true, once: true}); | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
console.warn('Drags initiated from the React Aria useDrag hook may only be dropped on a target created with useDrop. This ensures that a keyboard and screen reader accessible alternative is available.'); | ||
}, {once: true}); | ||
state.x = e.clientX; | ||
@@ -178,2 +182,5 @@ state.y = e.clientY; | ||
let onDrag = (e: DragEvent) => { | ||
// Prevent the drag event from propagating to any parent draggables | ||
e.stopPropagation(); | ||
if (e.clientX === state.x && e.clientY === state.y) { | ||
@@ -196,2 +203,5 @@ return; | ||
let onDragEnd = (e: DragEvent) => { | ||
// Prevent the drag event from propagating to any parent draggables | ||
e.stopPropagation(); | ||
if (typeof options.onDragEnd === 'function') { | ||
@@ -278,3 +288,3 @@ let event: DragEndEvent = { | ||
let modality = useDragModality(); | ||
let message = !isDraggingRef.current ? MESSAGES[modality].start : MESSAGES[modality].end; | ||
let message = !isDragging ? MESSAGES[modality].start : MESSAGES[modality].end; | ||
@@ -340,2 +350,12 @@ let descriptionProps = useDescription(stringFormatter.format(message)); | ||
if (isDisabled) { | ||
return { | ||
dragProps: { | ||
draggable: 'false' | ||
}, | ||
dragButtonProps: {}, | ||
isDragging: false | ||
}; | ||
} | ||
return { | ||
@@ -353,4 +373,4 @@ dragProps: { | ||
}, | ||
isDragging: isDraggingRef.current | ||
isDragging | ||
}; | ||
} |
@@ -15,3 +15,3 @@ /* | ||
import {globalDndState, setDraggingCollectionRef} from './utils'; | ||
import {RefObject} from 'react'; | ||
import {RefObject} from '@react-types/shared'; | ||
@@ -24,3 +24,3 @@ export interface DraggableCollectionOptions {} | ||
*/ | ||
export function useDraggableCollection(props: DraggableCollectionOptions, state: DraggableCollectionState, ref: RefObject<HTMLElement>): void { | ||
export function useDraggableCollection(props: DraggableCollectionOptions, state: DraggableCollectionState, ref: RefObject<HTMLElement | null>): void { | ||
// Update global DnD state if this keys within this collection are dragged | ||
@@ -27,0 +27,0 @@ let {draggingCollectionRef} = globalDndState; |
@@ -14,10 +14,10 @@ /* | ||
import {AriaButtonProps} from '@react-types/button'; | ||
import {clearGlobalDnDState, isInternalDropOperation, setDraggingKeys} from './utils'; | ||
import {clearGlobalDnDState, isInternalDropOperation, setDraggingKeys, useDragModality} from './utils'; | ||
import {DraggableCollectionState} from '@react-stately/dnd'; | ||
import {HTMLAttributes, Key} from 'react'; | ||
import {HTMLAttributes} from 'react'; | ||
// @ts-ignore | ||
import intlMessages from '../intl/*.json'; | ||
import {Key} from '@react-types/shared'; | ||
import {useDescription} from '@react-aria/utils'; | ||
import {useDrag} from './useDrag'; | ||
import {useDragModality} from './utils'; | ||
import {useLocalizedStringFormatter} from '@react-aria/i18n'; | ||
@@ -68,4 +68,4 @@ | ||
export function useDraggableItem(props: DraggableItemProps, state: DraggableCollectionState): DraggableItemResult { | ||
let stringFormatter = useLocalizedStringFormatter(intlMessages); | ||
let isDisabled = state.selectionManager.isDisabled(props.key); | ||
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/dnd'); | ||
let isDisabled = state.isDisabled || state.selectionManager.isDisabled(props.key); | ||
let {dragProps, dragButtonProps} = useDrag({ | ||
@@ -97,4 +97,4 @@ getItems() { | ||
let isSelected = numKeysForDrag > 1 && state.selectionManager.isSelected(props.key); | ||
let dragButtonLabel: string; | ||
let description: string; | ||
let dragButtonLabel: string | undefined; | ||
let description: string | undefined; | ||
@@ -122,3 +122,4 @@ // Override description to include selected item count. | ||
} else { | ||
dragButtonLabel = stringFormatter.format('dragItem', {itemText: item?.textValue ?? ''}); | ||
let itemText = state.collection.getTextValue?.(props.key) ?? item?.textValue ?? ''; | ||
dragButtonLabel = stringFormatter.format('dragItem', {itemText}); | ||
} | ||
@@ -142,3 +143,3 @@ } | ||
if (e.altKey) { | ||
onKeyDownCapture(e); | ||
onKeyDownCapture?.(e); | ||
} | ||
@@ -149,3 +150,3 @@ }; | ||
if (e.altKey) { | ||
onKeyUpCapture(e); | ||
onKeyUpCapture?.(e); | ||
} | ||
@@ -152,0 +153,0 @@ }; |
@@ -13,8 +13,9 @@ /* | ||
import {DragEvent, HTMLAttributes, RefObject, useRef, useState} from 'react'; | ||
import {AriaButtonProps} from '@react-types/button'; | ||
import {DragEvent, HTMLAttributes, useRef, useState} from 'react'; | ||
import * as DragManager from './DragManager'; | ||
import {DragTypes, globalAllowedDropOperations, globalDndState, readFromDataTransfer, setGlobalDnDState, setGlobalDropEffect} from './utils'; | ||
import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, DROP_OPERATION_ALLOWED, DROP_OPERATION_TO_DROP_EFFECT} from './constants'; | ||
import {DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropMoveEvent, DropOperation, DragTypes as IDragTypes} from '@react-types/shared'; | ||
import {isIPad, isMac, useLayoutEffect} from '@react-aria/utils'; | ||
import {DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropMoveEvent, DropOperation, DragTypes as IDragTypes, RefObject} from '@react-types/shared'; | ||
import {isIPad, isMac, useEffectEvent, useLayoutEffect} from '@react-aria/utils'; | ||
import {useVirtualDrop} from './useVirtualDrop'; | ||
@@ -24,3 +25,3 @@ | ||
/** A ref for the droppable element. */ | ||
ref: RefObject<HTMLElement>, | ||
ref: RefObject<HTMLElement | null>, | ||
/** | ||
@@ -46,3 +47,12 @@ * A function returning the drop operation to be performed when items matching the given types are dropped | ||
/** Handler that is called when a valid drag is dropped on the drop target. */ | ||
onDrop?: (e: DropEvent) => void | ||
onDrop?: (e: DropEvent) => void, | ||
/** | ||
* Whether the item has an explicit focusable drop affordance to initiate accessible drag and drop mode. | ||
* If true, the dropProps will omit these event handlers, and they will be applied to dropButtonProps instead. | ||
*/ | ||
hasDropButton?: boolean, | ||
/** | ||
* Whether the drop target is disabled. If true, the drop target will not accept any drops. | ||
*/ | ||
isDisabled?: boolean | ||
} | ||
@@ -54,3 +64,5 @@ | ||
/** Whether the drop target is currently focused or hovered. */ | ||
isDropTarget: boolean | ||
isDropTarget: boolean, | ||
/** Props for the explicit drop button affordance, if any. */ | ||
dropButtonProps?: AriaButtonProps | ||
} | ||
@@ -65,2 +77,3 @@ | ||
export function useDrop(options: DropOptions): DropResult { | ||
let {hasDropButton, isDisabled} = options; | ||
let [isDropTarget, setDropTarget] = useState(false); | ||
@@ -276,37 +289,66 @@ let state = useRef({ | ||
let optionsRef = useRef(options); | ||
optionsRef.current = options; | ||
let onDropEnter = useEffectEvent((e: DropEnterEvent) => { | ||
if (typeof options.onDropEnter === 'function') { | ||
options.onDropEnter(e); | ||
} | ||
}); | ||
useLayoutEffect(() => DragManager.registerDropTarget({ | ||
element: optionsRef.current.ref.current, | ||
getDropOperation: optionsRef.current.getDropOperation, | ||
onDropEnter(e) { | ||
setDropTarget(true); | ||
if (typeof optionsRef.current.onDropEnter === 'function') { | ||
optionsRef.current.onDropEnter(e); | ||
} | ||
}, | ||
onDropExit(e) { | ||
setDropTarget(false); | ||
if (typeof optionsRef.current.onDropExit === 'function') { | ||
optionsRef.current.onDropExit(e); | ||
} | ||
}, | ||
onDrop(e) { | ||
if (typeof optionsRef.current.onDrop === 'function') { | ||
optionsRef.current.onDrop(e); | ||
} | ||
}, | ||
onDropActivate(e) { | ||
if (typeof optionsRef.current.onDropActivate === 'function') { | ||
optionsRef.current.onDropActivate(e); | ||
} | ||
let onDropExit = useEffectEvent((e: DropExitEvent) => { | ||
if (typeof options.onDropExit === 'function') { | ||
options.onDropExit(e); | ||
} | ||
}), [optionsRef]); | ||
}); | ||
let onDropActivate = useEffectEvent((e: DropActivateEvent) => { | ||
if (typeof options.onDropActivate === 'function') { | ||
options.onDropActivate(e); | ||
} | ||
}); | ||
let onKeyboardDrop = useEffectEvent((e: DropEvent) => { | ||
if (typeof options.onDrop === 'function') { | ||
options.onDrop(e); | ||
} | ||
}); | ||
let getDropOperationKeyboard = useEffectEvent((types: IDragTypes, allowedOperations: DropOperation[]) => { | ||
if (options.getDropOperation) { | ||
return options.getDropOperation(types, allowedOperations); | ||
} | ||
return allowedOperations[0]; | ||
}); | ||
let {ref} = options; | ||
useLayoutEffect(() => { | ||
if (isDisabled) { | ||
return; | ||
} | ||
return DragManager.registerDropTarget({ | ||
element: ref.current, | ||
getDropOperation: getDropOperationKeyboard, | ||
onDropEnter(e) { | ||
setDropTarget(true); | ||
onDropEnter(e); | ||
}, | ||
onDropExit(e) { | ||
setDropTarget(false); | ||
onDropExit(e); | ||
}, | ||
onDrop: onKeyboardDrop, | ||
onDropActivate | ||
}); | ||
}, [isDisabled, ref, getDropOperationKeyboard, onDropEnter, onDropExit, onKeyboardDrop, onDropActivate]); | ||
let {dropProps} = useVirtualDrop(); | ||
if (isDisabled) { | ||
return { | ||
dropProps: {}, | ||
dropButtonProps: {isDisabled: true}, | ||
isDropTarget: false | ||
}; | ||
} | ||
return { | ||
dropProps: { | ||
...dropProps, | ||
...(!hasDropButton && dropProps), | ||
onDragEnter, | ||
@@ -317,2 +359,3 @@ onDragOver, | ||
}, | ||
dropButtonProps: {...(hasDropButton && dropProps)}, | ||
isDropTarget | ||
@@ -319,0 +362,0 @@ }; |
@@ -15,5 +15,5 @@ /* | ||
import {DroppableCollectionState} from '@react-stately/dnd'; | ||
import {DropTarget} from '@react-types/shared'; | ||
import {DropTarget, Key, RefObject} from '@react-types/shared'; | ||
import {getDroppableCollectionId} from './utils'; | ||
import {HTMLAttributes, Key, RefObject} from 'react'; | ||
import {HTMLAttributes} from 'react'; | ||
// @ts-ignore | ||
@@ -45,11 +45,11 @@ import intlMessages from '../intl/*.json'; | ||
*/ | ||
export function useDropIndicator(props: DropIndicatorProps, state: DroppableCollectionState, ref: RefObject<HTMLElement>): DropIndicatorAria { | ||
export function useDropIndicator(props: DropIndicatorProps, state: DroppableCollectionState, ref: RefObject<HTMLElement | null>): DropIndicatorAria { | ||
let {target} = props; | ||
let {collection} = state; | ||
let stringFormatter = useLocalizedStringFormatter(intlMessages); | ||
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/dnd'); | ||
let dragSession = DragManager.useDragSession(); | ||
let {dropProps} = useDroppableItem(props, state, ref); | ||
let id = useId(); | ||
let getText = (key: Key) => collection.getItem(key)?.textValue; | ||
let getText = (key: Key) => collection.getTextValue?.(key) ?? collection.getItem(key)?.textValue; | ||
@@ -66,9 +66,16 @@ let label = ''; | ||
} else { | ||
let before = target.dropPosition === 'before' | ||
? collection.getKeyBefore(target.key) | ||
: target.key; | ||
let after = target.dropPosition === 'after' | ||
? collection.getKeyAfter(target.key) | ||
: target.key; | ||
let before: Key | null; | ||
let after: Key | null; | ||
if (collection.getFirstKey() === target.key && target.dropPosition === 'before') { | ||
before = null; | ||
} else { | ||
before = target.dropPosition === 'before' ? collection.getKeyBefore(target.key) : target.key; | ||
} | ||
if (collection.getLastKey() === target.key && target.dropPosition === 'after') { | ||
after = null; | ||
} else { | ||
after = target.dropPosition === 'after' ? collection.getKeyAfter(target.key) : target.key; | ||
} | ||
if (before && after) { | ||
@@ -75,0 +82,0 @@ label = stringFormatter.format('insertBetween', { |
@@ -31,8 +31,10 @@ /* | ||
DropTargetDelegate, | ||
Key, | ||
KeyboardDelegate, | ||
Node | ||
Node, | ||
RefObject | ||
} from '@react-types/shared'; | ||
import * as DragManager from './DragManager'; | ||
import {DroppableCollectionState} from '@react-stately/dnd'; | ||
import {HTMLAttributes, Key, RefObject, useCallback, useEffect, useRef} from 'react'; | ||
import {HTMLAttributes, useCallback, useEffect, useRef} from 'react'; | ||
import {mergeProps, useId, useLayoutEffect} from '@react-aria/utils'; | ||
@@ -42,2 +44,3 @@ import {setInteractionModality} from '@react-aria/interactions'; | ||
import {useDrop} from './useDrop'; | ||
import {useLocale} from '@react-aria/i18n'; | ||
@@ -60,2 +63,5 @@ export interface DroppableCollectionOptions extends DroppableCollectionProps { | ||
selectedKeys: Set<Key>, | ||
target: DropTarget, | ||
draggingKeys: Set<Key>, | ||
isInternal: boolean, | ||
timeout: ReturnType<typeof setTimeout> | ||
@@ -65,2 +71,3 @@ } | ||
const DROP_POSITIONS: DropPosition[] = ['before', 'on', 'after']; | ||
const DROP_POSITIONS_RTL: DropPosition[] = ['after', 'on', 'before']; | ||
@@ -71,3 +78,3 @@ /** | ||
*/ | ||
export function useDroppableCollection(props: DroppableCollectionOptions, state: DroppableCollectionState, ref: RefObject<HTMLElement>): DroppableCollectionResult { | ||
export function useDroppableCollection(props: DroppableCollectionOptions, state: DroppableCollectionState, ref: RefObject<HTMLElement | null>): DroppableCollectionResult { | ||
let localState = useRef({ | ||
@@ -218,15 +225,89 @@ props, | ||
let droppingState = useRef<DroppingState>(null); | ||
let onDrop = useCallback((e: DropEvent, target: DropTarget) => { | ||
let updateFocusAfterDrop = useCallback(() => { | ||
let {state} = localState; | ||
if (droppingState.current) { | ||
let { | ||
target, | ||
collection: prevCollection, | ||
selectedKeys: prevSelectedKeys, | ||
focusedKey: prevFocusedKey, | ||
isInternal, | ||
draggingKeys | ||
} = droppingState.current; | ||
// If an insert occurs during a drop, we want to immediately select these items to give | ||
// feedback to the user that a drop occurred. Only do this if the selection didn't change | ||
// since the drop started so we don't override if the user or application did something. | ||
if ( | ||
state.collection.size > prevCollection.size && | ||
state.selectionManager.isSelectionEqual(prevSelectedKeys) | ||
) { | ||
let newKeys = new Set<Key>(); | ||
for (let key of state.collection.getKeys()) { | ||
if (!prevCollection.getItem(key)) { | ||
newKeys.add(key); | ||
} | ||
} | ||
// Focus the collection. | ||
state.selectionManager.setFocused(true); | ||
state.selectionManager.setSelectedKeys(newKeys); | ||
// If the focused item didn't change since the drop occurred, also focus the first | ||
// inserted item. If selection is disabled, then also show the focus ring so there | ||
// is some indication that items were added. | ||
if (state.selectionManager.focusedKey === prevFocusedKey) { | ||
let first = newKeys.keys().next().value; | ||
let item = state.collection.getItem(first); | ||
// If this is a cell, focus the parent row. | ||
if (item?.type === 'cell') { | ||
first = item.parentKey; | ||
} | ||
state.selectionManager.setFocusedKey(first); | ||
if (state.selectionManager.selectionMode === 'none') { | ||
setInteractionModality('keyboard'); | ||
} | ||
} | ||
} else if ( | ||
state.selectionManager.focusedKey === prevFocusedKey && | ||
isInternal && | ||
target.type === 'item' && | ||
target.dropPosition !== 'on' && | ||
draggingKeys.has(state.collection.getItem(prevFocusedKey)?.parentKey) | ||
) { | ||
// Focus row instead of cell when reordering. | ||
state.selectionManager.setFocusedKey(state.collection.getItem(prevFocusedKey).parentKey); | ||
setInteractionModality('keyboard'); | ||
} else if ( | ||
state.selectionManager.focusedKey === prevFocusedKey && | ||
target.type === 'item' && | ||
target.dropPosition === 'on' && | ||
state.collection.getItem(target.key) != null | ||
) { | ||
// If focus didn't move already (e.g. due to an insert), and the user dropped on an item, | ||
// focus that item and show the focus ring to give the user feedback that the drop occurred. | ||
// Also show the focus ring if the focused key is not selected, e.g. in case of a reorder. | ||
state.selectionManager.setFocusedKey(target.key); | ||
setInteractionModality('keyboard'); | ||
} else if (!state.selectionManager.isSelected(state.selectionManager.focusedKey)) { | ||
setInteractionModality('keyboard'); | ||
} | ||
state.selectionManager.setFocused(true); | ||
} | ||
}, [localState]); | ||
let onDrop = useCallback((e: DropEvent, target: DropTarget) => { | ||
let {state} = localState; | ||
// Save some state of the collection/selection before the drop occurs so we can compare later. | ||
let focusedKey = state.selectionManager.focusedKey; | ||
droppingState.current = { | ||
timeout: null, | ||
focusedKey, | ||
focusedKey: state.selectionManager.focusedKey, | ||
collection: state.collection, | ||
selectedKeys: state.selectionManager.selectedKeys | ||
selectedKeys: state.selectionManager.selectedKeys, | ||
draggingKeys: globalDndState.draggingKeys, | ||
isInternal: isInternalDropOperation(ref), | ||
target | ||
}; | ||
@@ -245,22 +326,9 @@ | ||
// Wait for a short time period after the onDrop is called to allow the data to be read asynchronously | ||
// and for React to re-render. If an insert occurs during this time, it will be selected/focused below. | ||
// If items are not "immediately" inserted by the onDrop handler, the application will need to handle | ||
// selecting and focusing those items themselves. | ||
// and for React to re-render. If the collection didn't already change during this time (handled below), | ||
// update the focused key here. | ||
droppingState.current.timeout = setTimeout(() => { | ||
// If focus didn't move already (e.g. due to an insert), and the user dropped on an item, | ||
// focus that item and show the focus ring to give the user feedback that the drop occurred. | ||
// Also show the focus ring if the focused key is not selected, e.g. in case of a reorder. | ||
let {state} = localState; | ||
if (target.type === 'item' && target.dropPosition === 'on' && state.collection.getItem(target.key) != null) { | ||
state.selectionManager.setFocusedKey(target.key); | ||
state.selectionManager.setFocused(true); | ||
setInteractionModality('keyboard'); | ||
} else if (!state.selectionManager.isSelected(focusedKey)) { | ||
setInteractionModality('keyboard'); | ||
} | ||
updateFocusAfterDrop(); | ||
droppingState.current = null; | ||
}, 50); | ||
}, [localState, defaultOnDrop]); | ||
}, [localState, defaultOnDrop, ref, updateFocusAfterDrop]); | ||
@@ -277,38 +345,11 @@ // eslint-disable-next-line arrow-body-style | ||
useLayoutEffect(() => { | ||
// If an insert occurs during a drop, we want to immediately select these items to give | ||
// feedback to the user that a drop occurred. Only do this if the selection didn't change | ||
// since the drop started so we don't override if the user or application did something. | ||
if ( | ||
droppingState.current && | ||
state.selectionManager.isFocused && | ||
state.collection.size > droppingState.current.collection.size && | ||
state.selectionManager.isSelectionEqual(droppingState.current.selectedKeys) | ||
) { | ||
let newKeys = new Set<Key>(); | ||
for (let key of state.collection.getKeys()) { | ||
if (!droppingState.current.collection.getItem(key)) { | ||
newKeys.add(key); | ||
} | ||
} | ||
state.selectionManager.setSelectedKeys(newKeys); | ||
// If the focused item didn't change since the drop occurred, also focus the first | ||
// inserted item. If selection is disabled, then also show the focus ring so there | ||
// is some indication that items were added. | ||
if (state.selectionManager.focusedKey === droppingState.current.focusedKey) { | ||
let first = newKeys.keys().next().value; | ||
state.selectionManager.setFocusedKey(first); | ||
if (state.selectionManager.selectionMode === 'none') { | ||
setInteractionModality('keyboard'); | ||
} | ||
} | ||
droppingState.current = null; | ||
// If the collection changed after a drop, update the focused key. | ||
if (droppingState.current && state.collection !== droppingState.current.collection) { | ||
updateFocusAfterDrop(); | ||
} | ||
}); | ||
let {direction} = useLocale(); | ||
useEffect(() => { | ||
let getNextTarget = (target: DropTarget, wrap = true): DropTarget => { | ||
let getNextTarget = (target: DropTarget, wrap = true, horizontal = false): DropTarget => { | ||
if (!target) { | ||
@@ -321,22 +362,34 @@ return { | ||
let {keyboardDelegate} = localState.props; | ||
let nextKey = target.type === 'item' | ||
? keyboardDelegate.getKeyBelow(target.key) | ||
: keyboardDelegate.getFirstKey(); | ||
let dropPosition: DropPosition = 'before'; | ||
let nextKey: Key; | ||
if (target?.type === 'item') { | ||
nextKey = horizontal ? keyboardDelegate.getKeyRightOf(target.key) : keyboardDelegate.getKeyBelow(target.key); | ||
} else { | ||
nextKey = horizontal && direction === 'rtl' ? keyboardDelegate.getLastKey() : keyboardDelegate.getFirstKey(); | ||
} | ||
let dropPositions = horizontal && direction === 'rtl' ? DROP_POSITIONS_RTL : DROP_POSITIONS; | ||
let dropPosition: DropPosition = dropPositions[0]; | ||
if (target.type === 'item') { | ||
let positionIndex = DROP_POSITIONS.indexOf(target.dropPosition); | ||
let nextDropPosition = DROP_POSITIONS[positionIndex + 1]; | ||
if (positionIndex < DROP_POSITIONS.length - 1 && !(nextDropPosition === 'after' && nextKey != null)) { | ||
return { | ||
type: 'item', | ||
key: target.key, | ||
dropPosition: nextDropPosition | ||
}; | ||
} | ||
// If the the keyboard delegate returned the next key in the collection, | ||
// first try the other positions in the current key. Otherwise (e.g. in a grid layout), | ||
// jump to the same drop position in the new key. | ||
let nextCollectionKey = horizontal && direction === 'rtl' ? localState.state.collection.getKeyBefore(target.key) : localState.state.collection.getKeyAfter(target.key); | ||
if (nextKey == null || nextKey === nextCollectionKey) { | ||
let positionIndex = dropPositions.indexOf(target.dropPosition); | ||
let nextDropPosition = dropPositions[positionIndex + 1]; | ||
if (positionIndex < dropPositions.length - 1 && !(nextDropPosition === dropPositions[2] && nextKey != null)) { | ||
return { | ||
type: 'item', | ||
key: target.key, | ||
dropPosition: nextDropPosition | ||
}; | ||
} | ||
// If the last drop position was 'after', then 'before' on the next key is equivalent. | ||
// Switch to 'on' instead. | ||
if (target.dropPosition === 'after') { | ||
dropPosition = 'on'; | ||
// If the last drop position was 'after', then 'before' on the next key is equivalent. | ||
// Switch to 'on' instead. | ||
if (target.dropPosition === dropPositions[2]) { | ||
dropPosition = 'on'; | ||
} | ||
} else { | ||
dropPosition = target.dropPosition; | ||
} | ||
@@ -362,24 +415,36 @@ } | ||
let getPreviousTarget = (target: DropTarget, wrap = true): DropTarget => { | ||
let getPreviousTarget = (target: DropTarget, wrap = true, horizontal = false): DropTarget => { | ||
let {keyboardDelegate} = localState.props; | ||
let nextKey = target?.type === 'item' | ||
? keyboardDelegate.getKeyAbove(target.key) | ||
: keyboardDelegate.getLastKey(); | ||
let dropPosition: DropPosition = !target || target.type === 'root' ? 'after' : 'on'; | ||
let nextKey: Key; | ||
if (target?.type === 'item') { | ||
nextKey = horizontal ? keyboardDelegate.getKeyLeftOf(target.key) : keyboardDelegate.getKeyAbove(target.key); | ||
} else { | ||
nextKey = horizontal && direction === 'rtl' ? keyboardDelegate.getFirstKey() : keyboardDelegate.getLastKey(); | ||
} | ||
let dropPositions = horizontal && direction === 'rtl' ? DROP_POSITIONS_RTL : DROP_POSITIONS; | ||
let dropPosition: DropPosition = !target || target.type === 'root' ? dropPositions[2] : 'on'; | ||
if (target?.type === 'item') { | ||
let positionIndex = DROP_POSITIONS.indexOf(target.dropPosition); | ||
let nextDropPosition = DROP_POSITIONS[positionIndex - 1]; | ||
if (positionIndex > 0 && nextDropPosition !== 'after') { | ||
return { | ||
type: 'item', | ||
key: target.key, | ||
dropPosition: nextDropPosition | ||
}; | ||
} | ||
// If the the keyboard delegate returned the previous key in the collection, | ||
// first try the other positions in the current key. Otherwise (e.g. in a grid layout), | ||
// jump to the same drop position in the new key. | ||
let prevCollectionKey = horizontal && direction === 'rtl' ? localState.state.collection.getKeyAfter(target.key) : localState.state.collection.getKeyBefore(target.key); | ||
if (nextKey == null || nextKey === prevCollectionKey) { | ||
let positionIndex = dropPositions.indexOf(target.dropPosition); | ||
let nextDropPosition = dropPositions[positionIndex - 1]; | ||
if (positionIndex > 0 && nextDropPosition !== dropPositions[2]) { | ||
return { | ||
type: 'item', | ||
key: target.key, | ||
dropPosition: nextDropPosition | ||
}; | ||
} | ||
// If the last drop position was 'before', then 'after' on the previous key is equivalent. | ||
// Switch to 'on' instead. | ||
if (target.dropPosition === 'before') { | ||
dropPosition = 'on'; | ||
// If the last drop position was 'before', then 'after' on the previous key is equivalent. | ||
// Switch to 'on' instead. | ||
if (target.dropPosition === dropPositions[0]) { | ||
dropPosition = 'on'; | ||
} | ||
} else { | ||
dropPosition = target.dropPosition; | ||
} | ||
@@ -441,2 +506,3 @@ } | ||
element: ref.current, | ||
preventFocusOnDrop: true, | ||
getDropOperation(types, allowedOperations) { | ||
@@ -553,2 +619,16 @@ if (localState.state.target) { | ||
} | ||
case 'ArrowLeft': { | ||
if (keyboardDelegate.getKeyLeftOf) { | ||
let target = nextValidTarget(localState.state.target, types, drag.allowedDropOperations, (target, wrap) => getPreviousTarget(target, wrap, true)); | ||
localState.state.setTarget(target); | ||
} | ||
break; | ||
} | ||
case 'ArrowRight': { | ||
if (keyboardDelegate.getKeyRightOf) { | ||
let target = nextValidTarget(localState.state.target, types, drag.allowedDropOperations, (target, wrap) => getNextTarget(target, wrap, true)); | ||
localState.state.setTarget(target); | ||
} | ||
break; | ||
} | ||
case 'Home': { | ||
@@ -655,3 +735,3 @@ if (keyboardDelegate.getFirstKey) { | ||
}); | ||
}, [localState, ref, onDrop]); | ||
}, [localState, ref, onDrop, direction]); | ||
@@ -658,0 +738,0 @@ let id = useId(); |
@@ -15,5 +15,5 @@ /* | ||
import {DroppableCollectionState} from '@react-stately/dnd'; | ||
import {DropTarget} from '@react-types/shared'; | ||
import {DropTarget, RefObject} from '@react-types/shared'; | ||
import {getDroppableCollectionRef, getTypes, globalDndState, isInternalDropOperation} from './utils'; | ||
import {HTMLAttributes, RefObject, useEffect} from 'react'; | ||
import {HTMLAttributes, useEffect} from 'react'; | ||
import {useVirtualDrop} from './useVirtualDrop'; | ||
@@ -36,3 +36,3 @@ | ||
*/ | ||
export function useDroppableItem(options: DroppableItemOptions, state: DroppableCollectionState, ref: RefObject<HTMLElement>): DroppableItemResult { | ||
export function useDroppableItem(options: DroppableItemOptions, state: DroppableCollectionState, ref: RefObject<HTMLElement | null>): DroppableItemResult { | ||
let {dropProps} = useVirtualDrop(); | ||
@@ -39,0 +39,0 @@ let droppableCollectionRef = getDroppableCollectionRef(state); |
@@ -32,3 +32,3 @@ /* | ||
export function useVirtualDrop(): VirtualDropResult { | ||
let stringFormatter = useLocalizedStringFormatter(intlMessages); | ||
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/dnd'); | ||
let modality = useDragModality(); | ||
@@ -35,0 +35,0 @@ let dragSession = DragManager.useDragSession(); |
@@ -14,10 +14,9 @@ /* | ||
import {CUSTOM_DRAG_TYPE, DROP_OPERATION, GENERIC_TYPE, NATIVE_DRAG_TYPES} from './constants'; | ||
import {DirectoryDropItem, DragItem, DropItem, FileDropItem, DragTypes as IDragTypes} from '@react-types/shared'; | ||
import {DirectoryDropItem, DragItem, DropItem, FileDropItem, DragTypes as IDragTypes, Key, RefObject, TextDropItem} from '@react-types/shared'; | ||
import {DroppableCollectionState} from '@react-stately/dnd'; | ||
import {getInteractionModality, useInteractionModality} from '@react-aria/interactions'; | ||
import {Key, RefObject} from 'react'; | ||
interface DroppableCollectionMap { | ||
id: string, | ||
ref: RefObject<HTMLElement> | ||
ref: RefObject<HTMLElement | null> | ||
} | ||
@@ -318,10 +317,25 @@ | ||
/** Returns whether a drop item contains text data. */ | ||
export function isTextDropItem(dropItem: DropItem): dropItem is TextDropItem { | ||
return dropItem.kind === 'text'; | ||
} | ||
/** Returns whether a drop item is a file. */ | ||
export function isFileDropItem(dropItem: DropItem): dropItem is FileDropItem { | ||
return dropItem.kind === 'file'; | ||
} | ||
/** Returns whether a drop item is a directory. */ | ||
export function isDirectoryDropItem(dropItem: DropItem): dropItem is DirectoryDropItem { | ||
return dropItem.kind === 'directory'; | ||
} | ||
// Global DnD collection state tracker. | ||
export interface DnDState { | ||
/** A ref for the of the drag items in the current drag session if any. */ | ||
draggingCollectionRef?: RefObject<HTMLElement>, | ||
draggingCollectionRef?: RefObject<HTMLElement | null>, | ||
/** The set of currently dragged keys. */ | ||
draggingKeys: Set<Key>, | ||
/** A ref for the collection that is targeted for a drop operation, if any. */ | ||
dropCollectionRef?: RefObject<HTMLElement> | ||
dropCollectionRef?: RefObject<HTMLElement | null> | ||
} | ||
@@ -331,3 +345,3 @@ | ||
export function setDraggingCollectionRef(ref: RefObject<HTMLElement>) { | ||
export function setDraggingCollectionRef(ref: RefObject<HTMLElement | null>) { | ||
globalDndState.draggingCollectionRef = ref; | ||
@@ -340,3 +354,3 @@ } | ||
export function setDropCollectionRef(ref: RefObject<HTMLElement>) { | ||
export function setDropCollectionRef(ref: RefObject<HTMLElement | null>) { | ||
globalDndState.dropCollectionRef = ref; | ||
@@ -355,3 +369,3 @@ } | ||
// Allows a droppable ref arg in case the global drop collection ref hasn't been set | ||
export function isInternalDropOperation(ref?: RefObject<HTMLElement>) { | ||
export function isInternalDropOperation(ref?: RefObject<HTMLElement | null>) { | ||
let {draggingCollectionRef, dropCollectionRef} = globalDndState; | ||
@@ -358,0 +372,0 @@ return draggingCollectionRef?.current != null && draggingCollectionRef.current === (ref?.current || dropCollectionRef?.current); |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Unidentified License
License(Experimental) Something that seems like a license was found, but its contents could not be matched with a known license.
Found 12 instances in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Manifest confusion
Supply chain riskThis package has inconsistent metadata. This could be malicious or caused by an error when publishing the package.
Found 1 instance in 1 package
1703771
12
275
16005
12
60
+ Added@swc/helpers@^0.5.0
+ Added@formatjs/ecma402-abstract@2.3.1(transitive)
+ Added@formatjs/fast-memoize@2.2.5(transitive)
+ Added@formatjs/icu-messageformat-parser@2.9.7(transitive)
+ Added@formatjs/icu-skeleton-parser@1.8.11(transitive)
+ Added@formatjs/intl-localematcher@0.5.9(transitive)
+ Added@internationalized/date@3.6.0(transitive)
+ Added@internationalized/message@3.1.6(transitive)
+ Added@internationalized/number@3.6.0(transitive)
+ Added@internationalized/string@3.2.5(transitive)
+ Added@react-aria/focus@3.19.0(transitive)
+ Added@react-aria/i18n@3.12.4(transitive)
+ Added@react-aria/interactions@3.22.5(transitive)
+ Added@react-aria/live-announcer@3.4.1(transitive)
+ Added@react-aria/overlays@3.24.0(transitive)
+ Added@react-aria/ssr@3.9.7(transitive)
+ Added@react-aria/utils@3.26.0(transitive)
+ Added@react-aria/visually-hidden@3.8.18(transitive)
+ Added@react-stately/collections@3.12.0(transitive)
+ Added@react-stately/dnd@3.5.0(transitive)
+ Added@react-stately/overlays@3.6.12(transitive)
+ Added@react-stately/selection@3.18.0(transitive)
+ Added@react-stately/utils@3.10.5(transitive)
+ Added@react-types/button@3.10.1(transitive)
+ Added@react-types/overlays@3.8.11(transitive)
+ Added@react-types/shared@3.26.0(transitive)
+ Added@swc/helpers@0.5.15(transitive)
+ Addedclsx@2.1.1(transitive)
+ Addeddecimal.js@10.4.3(transitive)
+ Addedintl-messageformat@10.7.10(transitive)
+ Addedreact@19.0.0(transitive)
+ Addedreact-dom@19.0.0(transitive)
+ Addedscheduler@0.25.0(transitive)
+ Addedtslib@2.8.1(transitive)
- Removed@babel/runtime@^7.6.2
- Removed@react-aria/visually-hidden@3.0.0-nightly.1875+a13802d8b
- Removed@babel/runtime@7.26.0(transitive)
- Removedjs-tokens@4.0.0(transitive)
- Removedloose-envify@1.4.0(transitive)
- Removedreact@18.3.1(transitive)
- Removedreact-dom@18.3.1(transitive)
- Removedregenerator-runtime@0.14.1(transitive)
- Removedscheduler@0.23.2(transitive)
Updated@internationalized/string@^3.0.0-nightly-4980928d3-240906
Updated@react-aria/interactions@^3.0.0-nightly-4980928d3-240906
Updated@react-aria/live-announcer@^3.0.0-nightly-4980928d3-240906