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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions packages/@react-aria/dnd/src/useDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {globalDropEffect, setGlobalAllowedDropOperations, setGlobalDropEffect, u
import intlMessages from '../intl/*.json';
import {useLocalizedStringFormatter} from '@react-aria/i18n';

const DRAG_BUTTON_ATTR = 'data-react-aria-drag-button';

export interface DragOptions {
/** Handler that is called when a drag operation is started. */
onDragStart?: (e: DragStartEvent) => void,
Expand All @@ -42,7 +44,13 @@ export interface DragOptions {
/**
* Whether the drag operation is disabled. If true, the element will not be draggable.
*/
isDisabled?: boolean
isDisabled?: boolean,
/**
* Controls where pointer dragging can start.
* `"item"` allows dragging from anywhere on the draggable item.
* `"dragButton"` requires mouse dragging to start from the drag button, if one is present.
*/
pointerDragSource?: 'item' | 'dragButton'
}

export interface DragResult {
Expand Down Expand Up @@ -74,7 +82,7 @@ const MESSAGES = {
* based drag and drop, in addition to full parity for keyboard and screen reader users.
*/
export function useDrag(options: DragOptions): DragResult {
let {hasDragButton, isDisabled} = options;
let {hasDragButton, isDisabled, pointerDragSource = 'item'} = options;
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/dnd');
let state = useRef({
options,
Expand All @@ -90,6 +98,8 @@ export function useDrag(options: DragOptions): DragResult {
};
let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
let modalityOnPointerDown = useRef<string>(null);
let pointerTypeOnPointerDown = useRef<string>(null);
let isPointerDownOnDragButton = useRef(false);

let onDragStart = (e: DragEvent) => {
if (e.defaultPrevented) {
Expand All @@ -107,6 +117,16 @@ export function useDrag(options: DragOptions): DragResult {
return;
}

if (hasDragButton && pointerDragSource === 'dragButton' && (pointerTypeOnPointerDown.current == null || pointerTypeOnPointerDown.current === 'mouse')) {
let hasRenderedDragButton = !!(e.currentTarget as HTMLElement).querySelector(`[${DRAG_BUTTON_ATTR}]`);
let target = getEventTarget(e);
let isDragStartOnDragButton = target instanceof Element && !!target.closest(`[${DRAG_BUTTON_ATTR}]`);
if (hasRenderedDragButton && !isPointerDownOnDragButton.current && !isDragStartOnDragButton) {
e.preventDefault();
return;
}
}

if (typeof options.onDragStart === 'function') {
options.onDragStart({
type: 'dragstart',
Expand Down Expand Up @@ -238,6 +258,8 @@ export function useDrag(options: DragOptions): DragResult {
removeAllGlobalListeners();
setGlobalAllowedDropOperations(DROP_OPERATION.none);
setGlobalDropEffect(undefined);
pointerTypeOnPointerDown.current = null;
isPointerDownOnDragButton.current = false;
};

// If the dragged element is removed from the DOM via onDrop, onDragEnd won't fire: https://bugzilla.mozilla.org/show_bug.cgi?id=460801
Expand Down Expand Up @@ -373,18 +395,30 @@ export function useDrag(options: DragOptions): DragResult {
};
}

let onPointerDownCapture: HTMLAttributes<HTMLElement>['onPointerDownCapture'] = (e) => {
pointerTypeOnPointerDown.current = e.pointerType;
if (hasDragButton && pointerDragSource === 'dragButton' && e.pointerType === 'mouse') {
let target = getEventTarget(e);
isPointerDownOnDragButton.current = target instanceof Element && !!target.closest(`[${DRAG_BUTTON_ATTR}]`);
} else {
isPointerDownOnDragButton.current = false;
}
};

return {
dragProps: {
...interactions,
draggable: 'true',
onPointerDownCapture,
onDragStart,
onDrag,
onDragEnd
},
dragButtonProps: {
...descriptionProps,
onPress
},
onPress,
[DRAG_BUTTON_ATTR]: 'true'
} as AriaButtonProps,
isDragging
};
}
7 changes: 7 additions & 0 deletions packages/@react-aria/dnd/src/useDraggableItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export interface DraggableItemProps {
* If true, the dragProps will omit these event handlers, and they will be applied to dragButtonProps instead.
*/
hasDragButton?: boolean,
/**
* Controls where pointer dragging can start for mouse input.
* `"item"` allows dragging from anywhere on the item.
* `"dragButton"` requires dragging to start from the drag button, if one is present.
*/
pointerDragSource?: 'item' | 'dragButton',
/**
* Whether the item has a primary action (e.g. Enter key or long press) that would
* conflict with initiating accessible drag and drop. If true, the Alt key must be held to
Expand Down Expand Up @@ -73,6 +79,7 @@ export function useDraggableItem(props: DraggableItemProps, state: DraggableColl
preview: state.preview,
getAllowedDropOperations: state.getAllowedDropOperations,
hasDragButton: props.hasDragButton,
pointerDragSource: props.pointerDragSource,
onDragStart(e) {
state.startDrag(props.key, e);
// Track draggingKeys for useDroppableCollection's default onDrop handler and useDroppableCollectionState's default getDropOperation
Expand Down
88 changes: 88 additions & 0 deletions packages/@react-aria/dnd/test/dnd.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {DataTransfer, DataTransferItem, DragEvent, FileSystemDirectoryEntry, Fil
import {Draggable, Droppable} from './examples';
import {DragTypes} from '../src/utils';
import React, {useEffect} from 'react';
import {useButton} from '@react-aria/button';
import {useDrag} from '../src';
import userEvent from '@testing-library/user-event';

function pointerEvent(type, opts) {
Expand All @@ -34,6 +36,35 @@ function pointerEvent(type, opts) {
return evt;
}

function DraggableWithDragButton(props) {
let {dragProps, dragButtonProps, isDragging} = useDrag({
getItems() {
return [{
'text/plain': 'hello world'
}];
},
hasDragButton: true,
...props
});
let buttonRef = React.useRef(null);
let {buttonProps} = useButton({
...dragButtonProps,
elementType: 'div'
}, buttonRef);

return (
<div
{...dragProps}
role="button"
tabIndex={0}
data-dragging={isDragging}
data-testid="drag-root">
<div {...buttonProps} ref={buttonRef} data-testid="drag-button">Drag handle</div>
<div>Drag me</div>
</div>
);
}

describe('useDrag and useDrop', function () {
let user;
beforeAll(() => {
Expand Down Expand Up @@ -210,6 +241,49 @@ describe('useDrag and useDrop', function () {
expect(onDrop).not.toHaveBeenCalled();
});

it('should require mouse drags to start from the drag button when pointerDragSource is dragButton', () => {
let onDragStart = jest.fn();
let tree = render(<DraggableWithDragButton pointerDragSource="dragButton" onDragStart={onDragStart} />);
let draggable = tree.getByTestId('drag-root');
let dataTransfer = new DataTransfer();

fireEvent.pointerDown(draggable, {pointerType: 'mouse', button: 0, pointerId: 1});
fireEvent(draggable, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0}));
act(() => jest.runAllTimers());

expect(onDragStart).not.toHaveBeenCalled();
expect(draggable).toHaveAttribute('data-dragging', 'false');
});

it('should allow mouse drags from the drag button when pointerDragSource is dragButton', () => {
let onDragStart = jest.fn();
let tree = render(<DraggableWithDragButton pointerDragSource="dragButton" onDragStart={onDragStart} />);
let draggable = tree.getByTestId('drag-root');
let dragButton = tree.getByTestId('drag-button');
let dataTransfer = new DataTransfer();

fireEvent.pointerDown(dragButton, {pointerType: 'mouse', button: 0, pointerId: 1});
fireEvent(dragButton, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0}));
act(() => jest.runAllTimers());

expect(onDragStart).toHaveBeenCalledTimes(1);
expect(draggable).toHaveAttribute('data-dragging', 'true');
});

it('should fallback to item drags when pointerDragSource is dragButton and no drag button is rendered', () => {
let onDragStart = jest.fn();
let tree = render(<Draggable hasDragButton pointerDragSource="dragButton" onDragStart={onDragStart} />);
let draggable = tree.getByText('Drag me');
let dataTransfer = new DataTransfer();

fireEvent.pointerDown(draggable, {pointerType: 'mouse', button: 0, pointerId: 1});
fireEvent(draggable, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0}));
act(() => jest.runAllTimers());

expect(onDragStart).toHaveBeenCalledTimes(1);
expect(draggable).toHaveAttribute('data-dragging', 'true');
});

describe('events', () => {
it('fires onDragMove only when the drag actually moves', () => {
let onDragStart = jest.fn();
Expand Down Expand Up @@ -1544,6 +1618,20 @@ describe('useDrag and useDrop', function () {
expect(droppable2).toHaveAttribute('data-droptarget', 'false');
});

it('should support keyboard dragging when pointerDragSource is dragButton', async () => {
let onDragStart = jest.fn();
render(<>
<Draggable pointerDragSource="dragButton" onDragStart={onDragStart} />
<Droppable />
</>);

await user.tab();
await user.keyboard('{Enter}');
act(() => jest.runAllTimers());

expect(onDragStart).toHaveBeenCalledTimes(1);
});

it('useDrag should support isDisabled', async () => {
let onDragStart = jest.fn();
let onDragMove = jest.fn();
Expand Down
17 changes: 17 additions & 0 deletions packages/@react-spectrum/table/test/TableDnd.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,23 @@ describe('TableView', function () {
expect(dataTransfer._dragImage.y).toBe(5);
});

it('should start mouse drags from row content by default', function () {
let {getByRole} = render(
<DraggableTableView />
);

let grid = getByRole('grid');
let rowgroups = within(grid).getAllByRole('rowgroup');
let row = within(rowgroups[1]).getAllByRole('row')[0];
let cell = within(row).getAllByRole('rowheader')[0];
let dataTransfer = new DataTransfer();

fireEvent.pointerDown(cell, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0});
fireEvent(cell, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0}));

expect(onDragStart).toHaveBeenCalledTimes(1);
});


it('should allow drag and drop of a single row', async function () {
let {getByRole, getByText} = render(
Expand Down
1 change: 1 addition & 0 deletions packages/dev/s2-docs/pages/react-aria/Table.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@ function ReorderableTable() {

///- begin highlight -///
let {dragAndDropHooks} = useDragAndDrop({
pointerDragSource: 'dragButton',
getItems: (keys, items: typeof list.items) => items.map(item => ({
'text/plain': item.name
})),
Expand Down
3 changes: 2 additions & 1 deletion packages/dev/s2-docs/pages/react-aria/useDrag.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ function Draggable() {
let {dragProps, dragButtonProps, isDragging} = useDrag({
/*- begin highlight -*/
hasDragButton: true,
pointerDragSource: 'dragButton',
/*- end highlight -*/
getItems() {
return [{
Expand All @@ -267,7 +268,7 @@ function Draggable() {
return (
<div {...dragProps} className={`draggable ${isDragging ? 'dragging' : ''}`} style={{display: 'inline-flex', alignItems: 'center', gap: 5}}>
{/*- begin highlight -*/}
<span {...buttonProps} aria-label="Drag" ref={ref} style={{fontSize: 18}}>≡</span>
<span {...buttonProps} aria-label="Drag" ref={ref} style={{fontSize: 18, cursor: 'grab'}}>≡</span>
{/*- end highlight -*/}
<span>Some text</span>
<button onClick={() => alert('action')}>Action</button>
Expand Down
1 change: 1 addition & 0 deletions packages/dev/s2-docs/pages/react-aria/useDragExample.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

.draggable.dragging {
opacity: 0.5;
cursor: grabbing;
}

.droppable {
Expand Down
17 changes: 13 additions & 4 deletions packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export interface GridListRenderProps {
* @selector [data-layout="stack | grid"]
*/
layout: 'stack' | 'grid',
/**
* Where pointer dragging can start.
* @selector [data-pointer-drag-source="item | dragButton"]
*/
pointerDragSource?: 'item' | 'dragButton',
/**
* State of the grid list.
*/
Expand Down Expand Up @@ -222,6 +227,7 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
isRootDropTarget = dropState.isDropTarget({type: 'root'});
}

let pointerDragSource = (isListDraggable && !dragState?.isDisabled) ? dragAndDropHooks?.pointerDragSource : undefined;
let {focusProps, isFocused, isFocusVisible} = useFocusRing();
let isEmpty = filteredState.collection.size === 0;
let renderValues = {
Expand All @@ -230,6 +236,7 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
isFocused,
isFocusVisible,
layout,
pointerDragSource,
state: filteredState
};
let renderProps = useRenderProps({
Expand Down Expand Up @@ -266,7 +273,8 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
data-empty={isEmpty || undefined}
data-focused={isFocused || undefined}
data-focus-visible={isFocusVisible || undefined}
data-layout={layout}>
data-layout={layout}
data-pointer-drag-source={pointerDragSource}>
<Provider
values={[
[ListStateContext, filteredState],
Expand Down Expand Up @@ -403,6 +411,9 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function
let DOMProps = filterDOMProps(props as any, {global: true});
delete DOMProps.id;
delete DOMProps.onClick;
let dragButtonStyle: React.CSSProperties | undefined = dragAndDropHooks?.pointerDragSource === 'dragButton'
? undefined
: {pointerEvents: 'none'};

return (
<>
Expand Down Expand Up @@ -441,9 +452,7 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function
drag: {
...draggableItem?.dragButtonProps,
ref: dragButtonRef,
style: {
pointerEvents: 'none'
}
style: dragButtonStyle
}
}
}],
Expand Down
Loading