diff --git a/packages/@react-aria/dnd/src/useDrag.ts b/packages/@react-aria/dnd/src/useDrag.ts index d91c5b2dc6c..15a7f8d3af5 100644 --- a/packages/@react-aria/dnd/src/useDrag.ts +++ b/packages/@react-aria/dnd/src/useDrag.ts @@ -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, @@ -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 { @@ -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, @@ -90,6 +98,8 @@ export function useDrag(options: DragOptions): DragResult { }; let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners(); let modalityOnPointerDown = useRef(null); + let pointerTypeOnPointerDown = useRef(null); + let isPointerDownOnDragButton = useRef(false); let onDragStart = (e: DragEvent) => { if (e.defaultPrevented) { @@ -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', @@ -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 @@ -373,18 +395,30 @@ export function useDrag(options: DragOptions): DragResult { }; } + let onPointerDownCapture: HTMLAttributes['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 }; } diff --git a/packages/@react-aria/dnd/src/useDraggableItem.ts b/packages/@react-aria/dnd/src/useDraggableItem.ts index 6d5781ddeaa..504a28da386 100644 --- a/packages/@react-aria/dnd/src/useDraggableItem.ts +++ b/packages/@react-aria/dnd/src/useDraggableItem.ts @@ -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 @@ -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 diff --git a/packages/@react-aria/dnd/test/dnd.test.js b/packages/@react-aria/dnd/test/dnd.test.js index 36f984c5179..da047f03e1c 100644 --- a/packages/@react-aria/dnd/test/dnd.test.js +++ b/packages/@react-aria/dnd/test/dnd.test.js @@ -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) { @@ -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 ( +
+
Drag handle
+
Drag me
+
+ ); +} + describe('useDrag and useDrop', function () { let user; beforeAll(() => { @@ -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(); + 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(); + 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(); + 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(); @@ -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(<> + + + ); + + 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(); diff --git a/packages/@react-spectrum/table/test/TableDnd.test.js b/packages/@react-spectrum/table/test/TableDnd.test.js index 6e14b1ead8f..3f55ce83523 100644 --- a/packages/@react-spectrum/table/test/TableDnd.test.js +++ b/packages/@react-spectrum/table/test/TableDnd.test.js @@ -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( + + ); + + 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( diff --git a/packages/dev/s2-docs/pages/react-aria/Table.mdx b/packages/dev/s2-docs/pages/react-aria/Table.mdx index b84ae1716a4..c7117240c0a 100644 --- a/packages/dev/s2-docs/pages/react-aria/Table.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Table.mdx @@ -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 })), diff --git a/packages/dev/s2-docs/pages/react-aria/useDrag.mdx b/packages/dev/s2-docs/pages/react-aria/useDrag.mdx index 54395efda59..61c1f26594f 100644 --- a/packages/dev/s2-docs/pages/react-aria/useDrag.mdx +++ b/packages/dev/s2-docs/pages/react-aria/useDrag.mdx @@ -253,6 +253,7 @@ function Draggable() { let {dragProps, dragButtonProps, isDragging} = useDrag({ /*- begin highlight -*/ hasDragButton: true, + pointerDragSource: 'dragButton', /*- end highlight -*/ getItems() { return [{ @@ -267,7 +268,7 @@ function Draggable() { return (
{/*- begin highlight -*/} - + {/*- end highlight -*/} Some text diff --git a/packages/dev/s2-docs/pages/react-aria/useDragExample.css b/packages/dev/s2-docs/pages/react-aria/useDragExample.css index 9a546076f26..d6702eff7c8 100644 --- a/packages/dev/s2-docs/pages/react-aria/useDragExample.css +++ b/packages/dev/s2-docs/pages/react-aria/useDragExample.css @@ -7,6 +7,7 @@ .draggable.dragging { opacity: 0.5; + cursor: grabbing; } .droppable { diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index b98faeaa957..4b41ab264cb 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -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. */ @@ -222,6 +227,7 @@ function GridListInner({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 = { @@ -230,6 +236,7 @@ function GridListInner({props, collection, gridListRef: ref}: isFocused, isFocusVisible, layout, + pointerDragSource, state: filteredState }; let renderProps = useRenderProps({ @@ -266,7 +273,8 @@ function GridListInner({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}> @@ -441,9 +452,7 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent(ItemNode, function drag: { ...draggableItem?.dragButtonProps, ref: dragButtonRef, - style: { - pointerEvents: 'none' - } + style: dragButtonStyle } } }], diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index c1096ead793..6456ffed376 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -319,6 +319,11 @@ export interface TableRenderProps { * @selector [data-drop-target] */ isDropTarget: boolean, + /** + * Where pointer dragging can start. + * @selector [data-pointer-drag-source="item | dragButton"] + */ + pointerDragSource?: 'item' | 'dragButton', /** * State of the table. */ @@ -476,6 +481,8 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl isRootDropTarget = dropState.isDropTarget({type: 'root'}); } + let isListDraggable = !!(hasDragHooks && !dragState?.isDisabled); + let pointerDragSource = isListDraggable ? dragAndDropHooks?.pointerDragSource : undefined; let {focusProps, isFocused, isFocusVisible} = useFocusRing(); let renderProps = useRenderProps({ ...props, @@ -485,12 +492,11 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl isDropTarget: isRootDropTarget, isFocused, isFocusVisible, + pointerDragSource, state: filteredState } }); - let isListDraggable = !!(hasDragHooks && !dragState?.isDisabled); - let style = renderProps.style; let layoutState: TableColumnResizeState | null = null; if (tableContainerContext) { @@ -526,6 +532,7 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl slot={props.slot || undefined} onScroll={props.onScroll} data-allows-dragging={isListDraggable || undefined} + data-pointer-drag-source={pointerDragSource} data-drop-target={isRootDropTarget || undefined} data-focused={isFocused || undefined} data-focus-visible={isFocusVisible || undefined}> @@ -1230,6 +1237,9 @@ export const Row = /*#__PURE__*/ createBranchComponent( 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 ( <> @@ -1267,9 +1277,7 @@ export const Row = /*#__PURE__*/ createBranchComponent( drag: { ...draggableItem?.dragButtonProps, ref: dragButtonRef, - style: { - pointerEvents: 'none' - } + style: dragButtonStyle } } }], diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 004e7aff58b..9855a6a21ee 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -215,6 +215,11 @@ export interface TreeRenderProps { * @selector [data-allows-dragging] */ allowsDragging: boolean, + /** + * Where pointer dragging can start. + * @selector [data-pointer-drag-source="item | dragButton"] + */ + pointerDragSource?: 'item' | 'dragButton', /** * State of the tree. */ @@ -443,6 +448,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne } let isTreeDraggable = !!(hasDragHooks && !dragState?.isDisabled); + let pointerDragSource = isTreeDraggable ? dragAndDropHooks?.pointerDragSource : undefined; let {focusProps, isFocused, isFocusVisible} = useFocusRing(); let renderValues = { @@ -452,6 +458,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne isDropTarget: isRootDropTarget, selectionMode: state.selectionManager.selectionMode, allowsDragging: !!isTreeDraggable, + pointerDragSource, state }; @@ -494,7 +501,8 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne data-drop-target={isRootDropTarget || undefined} data-focus-visible={isFocusVisible || undefined} data-selection-mode={state.selectionManager.selectionMode === 'none' ? undefined : state.selectionManager.selectionMode} - data-allows-dragging={!!isTreeDraggable || undefined}> + data-allows-dragging={!!isTreeDraggable || undefined} + data-pointer-drag-source={pointerDragSource}> @@ -802,9 +813,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(TreeItemNode, { useDraggableItem?: (props: DraggableItemProps, state: DraggableCollectionState) => DraggableItemResult, DragPreview?: typeof DragPreview, renderDragPreview?: (items: DragItem[]) => JSX.Element | {element: JSX.Element, x: number, y: number}, - isVirtualDragging?: () => boolean + isVirtualDragging?: () => boolean, + pointerDragSource?: 'item' | 'dragButton' } interface DropHooks { @@ -91,7 +92,14 @@ export interface DragAndDropOptions extends Omit(options: DragAndDropOptions): Drag getItems, renderDragPreview, renderDropIndicator, - dropTargetDelegate + dropTargetDelegate, + pointerDragSource = 'item' } = options; let isDraggable = !!getItems; @@ -121,10 +130,13 @@ export function useDragAndDrop(options: DragAndDropOptions): Drag return useDraggableCollectionState({...props, ...options} as DraggableCollectionStateOptions); }; hooks.useDraggableCollection = useDraggableCollection; - hooks.useDraggableItem = useDraggableItem; + hooks.useDraggableItem = function useDraggableItemOverride(props: DraggableItemProps, state: DraggableCollectionState) { + return useDraggableItem({...props, pointerDragSource}, state); + }; hooks.DragPreview = DragPreview; hooks.renderDragPreview = renderDragPreview; hooks.isVirtualDragging = isVirtualDragging; + hooks.pointerDragSource = pointerDragSource; } if (isDroppable) { diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx index 6a0b8995ad2..e3ef69bcf69 100644 --- a/packages/react-aria-components/stories/Table.stories.tsx +++ b/packages/react-aria-components/stories/Table.stories.tsx @@ -34,6 +34,7 @@ const ReorderableTable = ({initialItems}: {initialItems: {id: string, name: stri let list = useListData({initialItems}); const {dragAndDropHooks} = useDragAndDrop({ + pointerDragSource: 'dragButton', getItems: keys => { return [...keys].filter(k => !!list.getItem(k)).map(k => { const item = list.getItem(k); @@ -381,6 +382,7 @@ function DndTableRender(props: DndTableProps): JSX.Element { }); let {dragAndDropHooks} = useDragAndDrop({ + pointerDragSource: 'dragButton', isDisabled: props.isDisabled, // Provide drag data in a custom format as well as plain text. getItems(keys) { @@ -462,7 +464,7 @@ function DndTableRender(props: DndTableProps): JSX.Element { {item => ( - + {item.id} {item.name} @@ -542,6 +544,7 @@ function DndTableWithNoValidDropTargetsRender(): JSX.Element { }); let {dragAndDropHooks} = useDragAndDrop({ + pointerDragSource: 'dragButton', getItems(keys) { return [...keys].filter(k => !!list.getItem(k)).map((key) => { let item = list.getItem(key); @@ -833,6 +836,7 @@ export const VirtualizedTable: TableStory = () => { }); let {dragAndDropHooks} = useDragAndDrop({ + pointerDragSource: 'dragButton', getItems: (keys) => { return [...keys].filter(k => !!list.getItem(k)).map(key => ({'text/plain': list.getItem(key)!.foo})); }, diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 515536d0e90..e73a71eb6dc 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -33,6 +33,7 @@ import { useDragAndDrop, Virtualizer } from '../'; +import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks'; import {getFocusableTreeWalker} from '@react-aria/focus'; import {GridListLoadMoreItem} from '../src/GridList'; import {installPointerEvent, User} from '@react-aria/test-utils'; @@ -905,6 +906,38 @@ describe('GridList', () => { expect(button).toHaveAttribute('aria-label', 'Drag Cat'); }); + it('should make the drag button pointer-interactive when pointerDragSource="dragButton"', () => { + let {getAllByRole, getByRole, rerender} = render(); + let button = getAllByRole('button')[0]; + let gridList = getByRole('grid'); + expect(button.style.pointerEvents).toBe('none'); + expect(gridList).toHaveAttribute('data-pointer-drag-source', 'item'); + + rerender(); + button = getAllByRole('button')[0]; + gridList = getByRole('grid'); + expect(button.style.pointerEvents).toBe(''); + expect(gridList).toHaveAttribute('data-pointer-drag-source', 'dragButton'); + }); + + it('should require mouse drags to start from drag button when pointerDragSource="dragButton"', () => { + let getItems = jest.fn((keys) => [...keys].map((key) => ({'text/plain': key}))); + let {getAllByRole} = render(); + + let row = getAllByRole('row')[0]; + let dragButton = within(row).getAllByRole('button')[0]; + let dataTransfer = new DataTransfer(); + + fireEvent.pointerDown(row, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0}); + fireEvent(row, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0})); + expect(getItems).toHaveBeenCalledTimes(0); + + dataTransfer = new DataTransfer(); + fireEvent.pointerDown(dragButton, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0}); + fireEvent(dragButton, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0})); + expect(getItems).toHaveBeenCalledTimes(1); + }); + it('should render drop indicators', async () => { let onReorder = jest.fn(); let {getAllByRole} = render( Test} />); diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js index 3b8b948a2a0..8a3ec701140 100644 --- a/packages/react-aria-components/test/Table.test.js +++ b/packages/react-aria-components/test/Table.test.js @@ -1310,6 +1310,41 @@ describe('Table', () => { expect(button).toHaveAttribute('aria-label', 'Drag Games'); }); + it('should make the drag button pointer-interactive when pointerDragSource="dragButton"', () => { + let {getAllByRole, getByRole, rerender} = render(); + let button = getAllByRole('button')[0]; + let table = getByRole('grid'); + expect(button.style.pointerEvents).toBe('none'); + expect(table).toHaveAttribute('data-pointer-drag-source', 'item'); + + rerender(); + button = getAllByRole('button')[0]; + table = getByRole('grid'); + expect(button.style.pointerEvents).toBe(''); + expect(table).toHaveAttribute('data-pointer-drag-source', 'dragButton'); + }); + + it('should require mouse drags to start from drag button when pointerDragSource="dragButton"', () => { + let getItems = jest.fn((keys) => [...keys].map((key) => ({'text/plain': key}))); + let {getByRole} = render(); + + let grid = getByRole('grid'); + let rowgroups = within(grid).getAllByRole('rowgroup'); + let row = within(rowgroups[1]).getAllByRole('row')[0]; + let dragCell = within(row).getAllByRole('rowheader')[0]; + let dragButton = within(row).getAllByRole('button')[0]; + + let dataTransfer = new DataTransfer(); + fireEvent.pointerDown(dragCell, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0}); + fireEvent(dragCell, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0})); + expect(getItems).toHaveBeenCalledTimes(0); + + dataTransfer = new DataTransfer(); + fireEvent.pointerDown(dragButton, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0}); + fireEvent(dragButton, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0})); + expect(getItems).toHaveBeenCalledTimes(1); + }); + it('should render drop indicators', async () => { let onReorder = jest.fn(); let {getAllByRole} = render( Test} />); @@ -1414,6 +1449,7 @@ describe('Table', () => { let table = getByRole('grid'); expect(table).not.toHaveAttribute('data-allows-dragging', 'true'); + expect(table).not.toHaveAttribute('data-pointer-drag-source'); expect(table).not.toHaveAttribute('draggable', 'true'); let rows = getAllByRole('row'); @@ -2012,18 +2048,16 @@ describe('Table', () => { act(() => jest.runAllTimers()); rows = getAllByRole('row'); - let dragCell = within(rows[1]).getAllByRole('rowheader')[0]; - let dataTransfer = new DataTransfer(); - fireEvent.pointerDown(dragCell, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0}); - fireEvent(dragCell, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0})); + fireEvent.pointerDown(dragButton, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0}); + fireEvent(dragButton, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0})); let dropTarget = rows[2]; - fireEvent.pointerMove(dragCell, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 1, clientY: 1}); - fireEvent(dragCell, new DragEvent('drag', {dataTransfer, clientX: 1, clientY: 1})); + fireEvent.pointerMove(dragButton, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 1, clientY: 1}); + fireEvent(dragButton, new DragEvent('drag', {dataTransfer, clientX: 1, clientY: 1})); fireEvent(dropTarget, new DragEvent('dragover', {dataTransfer, clientX: 1, clientY: 80})); - fireEvent.pointerUp(dragCell, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 1, clientY: 1}); + fireEvent.pointerUp(dragButton, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 1, clientY: 1}); fireEvent(dropTarget, new DragEvent('drop', {dataTransfer, clientX: 1, clientY: 80})); - fireEvent(dragCell, new DragEvent('dragend', {dataTransfer, clientX: 1, clientY: 1})); + fireEvent(dragButton, new DragEvent('dragend', {dataTransfer, clientX: 1, clientY: 1})); act(() => jest.runAllTimers()); rows = getAllByRole('row'); diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 9f5c483ffec..6c6b038c893 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -16,6 +16,7 @@ import {Button, Checkbox, Collection, DropIndicator, ListLayout, Text, Tree, Tre import {composeStories} from '@storybook/react'; // @ts-ignore import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks'; +import {Key} from 'react-aria-components'; import React from 'react'; import * as stories from '../stories/Tree.stories'; import {User} from '@react-aria/test-utils'; @@ -1797,6 +1798,38 @@ describe('Tree', () => { expect(button).toHaveAttribute('aria-label', 'Drag Projects'); }); + it('should make the drag button pointer-interactive when pointerDragSource="dragButton"', () => { + let {getAllByRole, getByRole, rerender} = render(); + let button = getAllByRole('button')[0]; + let tree = getByRole('treegrid'); + expect(button.style.pointerEvents).toBe('none'); + expect(tree).toHaveAttribute('data-pointer-drag-source', 'item'); + + rerender(); + button = getAllByRole('button')[0]; + tree = getByRole('treegrid'); + expect(button.style.pointerEvents).toBe(''); + expect(tree).toHaveAttribute('data-pointer-drag-source', 'dragButton'); + }); + + it('should require mouse drags to start from drag button when pointerDragSource="dragButton"', () => { + let getItems = jest.fn((keys: Set) => [...keys].map((key) => ({'text/plain': String(key)}))); + let {getAllByRole} = render(); + + let row = getAllByRole('row')[0]; + let dragButton = within(row).getAllByRole('button')[0]; + let dataTransfer = new DataTransfer(); + + fireEvent.pointerDown(row, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0}); + fireEvent(row, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0})); + expect(getItems).toHaveBeenCalledTimes(0); + + dataTransfer = new DataTransfer(); + fireEvent.pointerDown(dragButton, {pointerType: 'mouse', button: 0, pointerId: 1, clientX: 0, clientY: 0}); + fireEvent(dragButton, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0})); + expect(getItems).toHaveBeenCalledTimes(1); + }); + it('should render drop indicators', async () => { let onReorder = jest.fn(); let {getAllByRole} = render( Test} />); @@ -1898,6 +1931,7 @@ describe('Tree', () => { let tree = getByRole('treegrid'); expect(tree).not.toHaveAttribute('data-allows-dragging', 'true'); + expect(tree).not.toHaveAttribute('data-pointer-drag-source'); expect(tree).not.toHaveAttribute('draggable', 'true'); let rows = within(tree).getAllByRole('row'); diff --git a/starters/docs/src/Table.css b/starters/docs/src/Table.css index 0535caf926a..80920b29ab1 100644 --- a/starters/docs/src/Table.css +++ b/starters/docs/src/Table.css @@ -32,6 +32,14 @@ min-height: 100px; } + &[data-pointer-drag-source=dragButton] .drag-button { + cursor: grab; + } + + &[data-pointer-drag-source=dragButton] .react-aria-Row[data-dragging] .drag-button { + cursor: grabbing; + } + .react-aria-TableHeader { color: var(--text-color); }