diff --git a/packages/block-editor/src/components/list-view/index.js b/packages/block-editor/src/components/list-view/index.js
index 60c08abaf9089..ff56484dc7613 100644
--- a/packages/block-editor/src/components/list-view/index.js
+++ b/packages/block-editor/src/components/list-view/index.js
@@ -59,6 +59,7 @@ export const BLOCK_LIST_ITEM_HEIGHT = 36;
* @param {Object} props Components props.
* @param {string} props.id An HTML element id for the root element of ListView.
* @param {Array} props.blocks _deprecated_ Custom subset of block client IDs to be used instead of the default hierarchy.
+ * @param {?HTMLElement} props.dropZoneElement Optional element to be used as the drop zone.
* @param {?boolean} props.showBlockMovers Flag to enable block movers. Defaults to `false`.
* @param {?boolean} props.isExpanded Flag to determine whether nested levels are expanded by default. Defaults to `false`.
* @param {?boolean} props.showAppender Flag to show or hide the block appender. Defaults to `false`.
@@ -73,6 +74,7 @@ function ListViewComponent(
{
id,
blocks,
+ dropZoneElement,
showBlockMovers = false,
isExpanded = false,
showAppender = false,
@@ -123,7 +125,9 @@ function ListViewComponent(
const [ expandedState, setExpandedState ] = useReducer( expanded, {} );
- const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone();
+ const { ref: dropZoneRef, target: blockDropTarget } = useListViewDropZone( {
+ dropZoneElement,
+ } );
const elementRef = useRef();
const treeGridRef = useMergeRefs( [ elementRef, dropZoneRef, ref ] );
diff --git a/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js b/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js
index 1e14417291a7a..c77feae4d291f 100644
--- a/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js
+++ b/packages/block-editor/src/components/list-view/test/use-list-view-drop-zone.js
@@ -261,4 +261,21 @@ describe( 'getListViewDropTarget', () => {
expect( target ).toBeUndefined();
} );
+
+ it( 'should move below, and not nest when dragging lower than the bottom-most block', () => {
+ const singleBlock = [ { ...blocksData[ 0 ], innerBlockCount: 0 } ];
+
+ // This position is to the right of the block, but below the bottom of the block.
+ // This should result in the block being moved below the bottom-most block, and
+ // not being treated as a nesting gesture.
+ const position = { x: 160, y: 250 };
+ const target = getListViewDropTarget( singleBlock, position );
+
+ expect( target ).toEqual( {
+ blockIndex: 1,
+ clientId: 'block-1',
+ dropPosition: 'bottom',
+ rootClientId: '',
+ } );
+ } );
} );
diff --git a/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js b/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js
index ffcd15e529ab2..ba98da7843356 100644
--- a/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js
+++ b/packages/block-editor/src/components/list-view/use-list-view-drop-zone.js
@@ -152,7 +152,8 @@ function getNextNonDraggedBlock( blocksData, index ) {
* inner block.
*
* Determined based on nesting level indentation of the current block, plus
- * the indentation of the next level of nesting.
+ * the indentation of the next level of nesting. The vertical position of the
+ * cursor must also be within the block.
*
* @param {WPPoint} point The point representing the cursor position when dragging.
* @param {DOMRect} rect The rectangle.
@@ -161,7 +162,10 @@ function getNextNonDraggedBlock( blocksData, index ) {
function isNestingGesture( point, rect, nestingLevel = 1 ) {
const blockIndentPosition =
rect.left + nestingLevel * NESTING_LEVEL_INDENTATION;
- return point.x > blockIndentPosition + NESTING_LEVEL_INDENTATION;
+ return (
+ point.x > blockIndentPosition + NESTING_LEVEL_INDENTATION &&
+ point.y < rect.bottom
+ );
}
// Block navigation is always a vertical list, so only allow dropping
@@ -359,9 +363,12 @@ export function getListViewDropTarget( blocksData, position ) {
/**
* A react hook for implementing a drop zone in list view.
*
+ * @param {Object} props Named parameters.
+ * @param {?HTMLElement} [props.dropZoneElement] Optional element to be used as the drop zone.
+ *
* @return {WPListViewDropZoneTarget} The drop target.
*/
-export default function useListViewDropZone() {
+export default function useListViewDropZone( { dropZoneElement } ) {
const {
getBlockRootClientId,
getBlockIndex,
@@ -432,7 +439,12 @@ export default function useListViewDropZone() {
);
const ref = useDropZone( {
+ dropZoneElement,
onDrop: onBlockDrop,
+ onDragLeave() {
+ throttled.cancel();
+ setTarget( null );
+ },
onDragOver( event ) {
// `currentTarget` is only available while the event is being
// handled, so get it now and pass it to the thottled function.
diff --git a/packages/compose/src/hooks/use-drop-zone/README.md b/packages/compose/src/hooks/use-drop-zone/README.md
new file mode 100644
index 0000000000000..558a5f6866b54
--- /dev/null
+++ b/packages/compose/src/hooks/use-drop-zone/README.md
@@ -0,0 +1,71 @@
+# useDropZone (experimental)
+
+A hook to facilitate drag and drop handling within a designated drop zone area. An optional `dropZoneElement` can be provided, however by default the drop zone is bound by the area where the returned `ref` is assigned.
+
+When using a `dropZoneElement`, it is expected that the `ref` will be attached to a node that is a descendent of the `dropZoneElement`. Additionally, the element passed to `dropZoneElement` should be stored in state rather than a plain ref to ensure reactive updating when it changes.
+
+## Usage
+
+```js
+import { useDropZone } from '@wordpress/compose';
+import { useState } from '@wordpress/element';
+
+const WithWrapperDropZoneElement = () => {
+ const [ dropZoneElement, setDropZoneElement ] = useState( null );
+
+ const dropZoneRef = useDropZone(
+ {
+ dropZoneElement,
+ onDrop() => {
+ console.log( 'Dropped within the drop zone.' );
+ },
+ onDragEnter() => {
+ console.log( 'Dragging within the drop zone' );
+ }
+ }
+ )
+
+ return (
+
+ );
+};
+
+const WithoutWrapperDropZoneElement = () => {
+ const dropZoneRef = useDropZone(
+ {
+ onDrop() => {
+ console.log( 'Dropped within the drop zone.' );
+ },
+ onDragEnter() => {
+ console.log( 'Dragging within the drop zone' );
+ }
+ }
+ )
+
+ return (
+
+ );
+};
+```
+
+## Parameters
+
+- _props_ `Object`: Named parameters.
+- _props.dropZoneElement_ `HTMLElement`: Optional element to be used as the drop zone.
+- _props.isDisabled_ `boolean`: Whether or not to disable the drop zone.
+- _props.onDragStart_ `( e: DragEvent ) => void`: Called when dragging has started.
+- _props.onDragEnter_ `( e: DragEvent ) => void`: Called when the zone is entered.
+- _props.onDragOver_ `( e: DragEvent ) => void`: Called when the zone is moved within.
+- _props.onDragLeave_ `( e: DragEvent ) => void`: Called when the zone is left.
+- _props.onDragEnd_ `( e: MouseEvent ) => void`: Called when dragging has ended.
+- _props.onDrop_ `( e: DragEvent ) => void`: Called when dropping in the zone.
+
+_Returns_
+
+- `RefCallback< HTMLElement >`: Ref callback to be passed to the drop zone element.
diff --git a/packages/compose/src/hooks/use-drop-zone/index.js b/packages/compose/src/hooks/use-drop-zone/index.js
index d6388e09f0bf0..b537f0a5ab622 100644
--- a/packages/compose/src/hooks/use-drop-zone/index.js
+++ b/packages/compose/src/hooks/use-drop-zone/index.js
@@ -33,18 +33,20 @@ function useFreshRef( value ) {
/**
* A hook to facilitate drag and drop handling.
*
- * @param {Object} props Named parameters.
- * @param {boolean} [props.isDisabled] Whether or not to disable the drop zone.
- * @param {(e: DragEvent) => void} [props.onDragStart] Called when dragging has started.
- * @param {(e: DragEvent) => void} [props.onDragEnter] Called when the zone is entered.
- * @param {(e: DragEvent) => void} [props.onDragOver] Called when the zone is moved within.
- * @param {(e: DragEvent) => void} [props.onDragLeave] Called when the zone is left.
- * @param {(e: MouseEvent) => void} [props.onDragEnd] Called when dragging has ended.
- * @param {(e: DragEvent) => void} [props.onDrop] Called when dropping in the zone.
+ * @param {Object} props Named parameters.
+ * @param {?HTMLElement} [props.dropZoneElement] Optional element to be used as the drop zone.
+ * @param {boolean} [props.isDisabled] Whether or not to disable the drop zone.
+ * @param {(e: DragEvent) => void} [props.onDragStart] Called when dragging has started.
+ * @param {(e: DragEvent) => void} [props.onDragEnter] Called when the zone is entered.
+ * @param {(e: DragEvent) => void} [props.onDragOver] Called when the zone is moved within.
+ * @param {(e: DragEvent) => void} [props.onDragLeave] Called when the zone is left.
+ * @param {(e: MouseEvent) => void} [props.onDragEnd] Called when dragging has ended.
+ * @param {(e: DragEvent) => void} [props.onDrop] Called when dropping in the zone.
*
* @return {import('react').RefCallback} Ref callback to be passed to the drop zone element.
*/
export default function useDropZone( {
+ dropZoneElement,
isDisabled,
onDrop: _onDrop,
onDragStart: _onDragStart,
@@ -61,11 +63,16 @@ export default function useDropZone( {
const onDragOverRef = useFreshRef( _onDragOver );
return useRefEffect(
- ( element ) => {
+ ( elem ) => {
if ( isDisabled ) {
return;
}
+ // If a custom dropZoneRef is passed, use that instead of the element.
+ // This allows the dropzone to cover an expanded area, rather than
+ // be restricted to the area of the ref returned by this hook.
+ const element = dropZoneElement ?? elem;
+
let isDragging = false;
const { ownerDocument } = element;
@@ -228,6 +235,6 @@ export default function useDropZone( {
);
};
},
- [ isDisabled ]
+ [ isDisabled, dropZoneElement ] // Refresh when the passed in dropZoneElement changes.
);
}
diff --git a/packages/compose/src/hooks/use-drop-zone/test/index.js b/packages/compose/src/hooks/use-drop-zone/test/index.js
new file mode 100644
index 0000000000000..260d5cbbbdceb
--- /dev/null
+++ b/packages/compose/src/hooks/use-drop-zone/test/index.js
@@ -0,0 +1,63 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import useDropZone from '../';
+
+describe( 'useDropZone', () => {
+ const ComponentWithWrapperDropZone = () => {
+ const [ dropZoneElement, setDropZoneElement ] = useState( null );
+ const dropZoneRef = useDropZone( {
+ dropZoneElement,
+ } );
+
+ return (
+
+ );
+ };
+
+ const ComponentWithoutWrapperDropZone = () => {
+ const dropZoneRef = useDropZone( {} );
+
+ return (
+
+ );
+ };
+
+ it( 'will attach dropzone to outer wrapper', () => {
+ const { rerender } = render( );
+ // Ensure `useEffect` has run.
+ rerender( );
+
+ expect( screen.getByRole( 'main' ) ).toHaveAttribute(
+ 'data-is-drop-zone'
+ );
+ } );
+
+ it( 'will attach dropzone to element with dropZoneRef attached', () => {
+ const { rerender } = render( );
+ // Ensure `useEffect` has run.
+ rerender( );
+
+ expect( screen.getByRole( 'region' ) ).toHaveAttribute(
+ 'data-is-drop-zone'
+ );
+ } );
+} );
diff --git a/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js b/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js
index 67cb2ad11c4cb..69aaf573b840e 100644
--- a/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js
+++ b/packages/edit-post/src/components/secondary-sidebar/list-view-sidebar.js
@@ -41,6 +41,10 @@ export default function ListViewSidebar() {
}
}
+ // Use internal state instead of a ref to make sure that the component
+ // re-renders when the dropZoneElement updates.
+ const [ dropZoneElement, setDropZoneElement ] = useState( null );
+
const [ tab, setTab ] = useState( 'list-view' );
// This ref refers to the sidebar as a whole.
@@ -147,12 +151,13 @@ export default function ListViewSidebar() {
contentFocusReturnRef,
focusOnMountRef,
listViewRef,
+ setDropZoneElement,
] ) }
className="edit-post-editor__list-view-container"
>
{ tab === 'list-view' && (
-
+
) }
{ tab === 'outline' && }
diff --git a/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js b/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js
index 37c85216b9a35..e4eff5ad037dd 100644
--- a/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js
+++ b/packages/edit-site/src/components/secondary-sidebar/list-view-sidebar.js
@@ -9,6 +9,7 @@ import {
useMergeRefs,
} from '@wordpress/compose';
import { useDispatch } from '@wordpress/data';
+import { useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { closeSmall } from '@wordpress/icons';
import { ESCAPE } from '@wordpress/keycodes';
@@ -24,9 +25,14 @@ const { PrivateListView } = unlock( blockEditorPrivateApis );
export default function ListViewSidebar() {
const { setIsListViewOpened } = useDispatch( editSiteStore );
+ // Use internal state instead of a ref to make sure that the component
+ // re-renders when the dropZoneElement updates.
+ const [ dropZoneElement, setDropZoneElement ] = useState( null );
+
const focusOnMountRef = useFocusOnMount( 'firstElement' );
const headerFocusReturnRef = useFocusReturn();
const contentFocusReturnRef = useFocusReturn();
+
function closeOnEscape( event ) {
if ( event.keyCode === ESCAPE && ! event.defaultPrevented ) {
setIsListViewOpened( false );
@@ -55,9 +61,10 @@ export default function ListViewSidebar() {
ref={ useMergeRefs( [
contentFocusReturnRef,
focusOnMountRef,
+ setDropZoneElement,
] ) }
>
-
+
);
diff --git a/packages/edit-widgets/src/components/secondary-sidebar/list-view-sidebar.js b/packages/edit-widgets/src/components/secondary-sidebar/list-view-sidebar.js
index 6e828533ddb6a..eeff1ea0bf0f3 100644
--- a/packages/edit-widgets/src/components/secondary-sidebar/list-view-sidebar.js
+++ b/packages/edit-widgets/src/components/secondary-sidebar/list-view-sidebar.js
@@ -9,6 +9,7 @@ import {
useMergeRefs,
} from '@wordpress/compose';
import { useDispatch } from '@wordpress/data';
+import { useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { closeSmall } from '@wordpress/icons';
import { ESCAPE } from '@wordpress/keycodes';
@@ -21,9 +22,14 @@ import { store as editWidgetsStore } from '../../store';
export default function ListViewSidebar() {
const { setIsListViewOpened } = useDispatch( editWidgetsStore );
+ // Use internal state instead of a ref to make sure that the component
+ // re-renders when the dropZoneElement updates.
+ const [ dropZoneElement, setDropZoneElement ] = useState( null );
+
const focusOnMountRef = useFocusOnMount( 'firstElement' );
const headerFocusReturnRef = useFocusReturn();
const contentFocusReturnRef = useFocusReturn();
+
function closeOnEscape( event ) {
if ( event.keyCode === ESCAPE && ! event.defaultPrevented ) {
event.preventDefault();
@@ -53,9 +59,10 @@ export default function ListViewSidebar() {
ref={ useMergeRefs( [
contentFocusReturnRef,
focusOnMountRef,
+ setDropZoneElement,
] ) }
>
-
+
);