[kbn-dnd-package] Divide draggable and droppable (#177282)

## Summary

Refactors a `<DragDrop/>` component to `<Draggable/>` and
`<Droppable/>`.

1. Performance gains
Performance improvements – one rerender less and then longest render
dropped by 30-40% (for 6x slowdown from 250 to 150ms action). The main
reason is that the components that were switching between being
Draggable and Droppable don't have to do it anymore so we don't mount
and unmount components as often.

<img width="856" alt="Screenshot 2024-02-22 at 17 10 07"
src="1555d1e0-93e6-4037-8636-470ea5101a14">

<img width="834" alt="Screenshot 2024-02-22 at 17 11 27"
src="11db4f37-e1c8-4ca5-83b7-c59d7937ce9a">

2. Readability improvements
Now it's much easier to see if the component is a drop zone or a
draggable component. The logic that was mixed between these two is
untangled and the components are smaller thanks to it.

4. Better API
It's easier to add a `<Draggable/>` or `<Droppable/>` components.

6. Small bugs fixes
Flash of colors when starting dragging in reorder group
![Feb-22-2024
17-15-32](e63296f5-1bc5-455c-ad7b-05738511f16a)

Annotation cut content
<img width="297" alt="Screenshot 2024-02-22 at 17 17 49"
src="ce4ab3d1-2a89-49cc-9230-c2ade194d1a3">

Half-transparent content when dragging - it doesn't make sense.
<img width="284" alt="Screenshot 2024-02-22 at 17 16 51"
src="fcafc12e-2ec9-4f93-a224-c7b4c8852e09">

7. Rewriting all the dnd tests from the package to rtl. It's still not
ideal, but way more tested from user perspective and way more readable.

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marta Bondyra 2024-03-21 14:30:14 +01:00 committed by GitHub
parent 3baacf48d7
commit 095c0593c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2465 additions and 2800 deletions

View file

@ -15,7 +15,7 @@
*/
import React, { useMemo } from 'react';
import { DragDrop, DropOverlayWrapper, DropType, useDragDropContext } from '@kbn/dom-drag-drop';
import { DropOverlayWrapper, DropType, Droppable, useDragDropContext } from '@kbn/dom-drag-drop';
import { EuiEmptyPrompt, EuiPanel } from '@elastic/eui';
const DROP_PROPS = {
@ -48,8 +48,7 @@ export const ExampleDropZone: React.FC<ExampleDropZoneProps> = ({ onDropField })
const isDropAllowed = Boolean(onDroppingField);
return (
<DragDrop
draggable={false}
<Droppable
dropTypes={isDropAllowed ? DROP_PROPS.types : undefined}
value={DROP_PROPS.value}
order={DROP_PROPS.order}
@ -68,6 +67,6 @@ export const ExampleDropZone: React.FC<ExampleDropZoneProps> = ({ onDropField })
/>
</EuiPanel>
</DropOverlayWrapper>
</DragDrop>
</Droppable>
);
};

View file

@ -32,27 +32,27 @@ This enables your child application to share the same drag / drop context as the
An item can be both draggable and droppable at the same time, but for simplicity's sake, we'll treat these two cases separately.
To enable dragging an item, use `DragDrop` with both a `draggable` and a `value` attribute. Property `value` has to be of a type object with a unique `id` property.
To enable dragging an item, use `Draggable` with a `value` attribute. Property `value` has to be of a type object with a unique `id` property.
```js
<div className="field-list">
{fields.map((f) => (
<DragDrop key={f.id} className="field-list-item" value={f} draggable>
<Draggable key={f.id} className="field-list-item" value={f}>
{f.name}
</DragDrop>
</Draggable>
))}
</div>
```
## Dropping
To enable dropping, use `DragDrop` with both a `dropTypes` attribute that should be an array with at least one value and an `onDrop` handler attribute. `dropType` should only be truthy if is an item being dragged, and if a drop of the dragged item is supported.
To enable dropping, use `Droppable` with both a `dropTypes` attribute that should be an array with at least one value and an `onDrop` handler attribute. `dropType` should only be truthy if is an item being dragged, and if a drop of the dragged item is supported.
```js
const [ dndState ] = useDragDropContext()
return (
<DragDrop
<Droppable
className="axis"
dropTypes=['truthyValue']
onDrop={(item) => onChange([...items, item])}
@ -60,40 +60,48 @@ return (
{items.map((x) => (
<div>{x.name}</div>
))}
</DragDrop>
</Droppable>
);
```
### Reordering
To create a reordering group, surround the elements from the same group with a `ReorderProvider`:
To create a reordering group, the elements has to be surrounded with a `ReorderProvider`. They also need to be surrounded with draggable and droppable at the same time.
```js
<ReorderProvider>... elements from one group here ...</ReorderProvider>
```
The children `DragDrop` components must have props defined as in the example:
The children `Draggable`/`Droppable` components must have props defined as in the example:
```js
<ReorderProvider>
<div className="field-list">
{fields.map((f) => (
<DragDrop
<Draggable
key={f.id}
draggable
dragType="move"
dropTypes={["reorder"]} // generally shouldn't be set until a drag operation has started
reorderableGroup={fields} // consists all reorderable elements in the group, eg. [{id:'3'}, {id:'5'}, {id:'1'}]
value={{
id: f.id,
humanData: {
label: 'Label'
}
}}
onDrop={/*handler*/}
>
{f.name}
</DragDrop>
>
<Droppable
dropTypes={["reorder"]} // generally shouldn't be set until a drag operation has started
reorderableGroup={fields} // consists all reorderable elements in the group, eg. [{id:'3'}, {id:'5'}, {id:'1'}]
value={{
id: f.id,
humanData: {
label: 'Label'
}
}}
onDrop={/*handler*/}
>
{f.name}
</Droppable>
</Draggable>
))}
</div>
</ReorderProvider>

View file

@ -14,7 +14,8 @@ export {
type DraggingIdentifier,
type DragDropAction,
type DropOverlayWrapperProps,
DragDrop,
Draggable,
Droppable,
useDragDropContext,
RootDragDropProvider,
ChildDragDropProvider,

View file

@ -1,58 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DragDrop defined dropTypes is reflected in the className 1`] = `
<div
class="domDragDrop__container"
data-test-subj="domDragDropContainer"
>
<button
class="domDragDrop domDragDrop-isDroppable domDragDrop-isDropTarget"
data-test-subj="domDragDrop"
>
Hello!
</button>
</div>
`;
exports[`DragDrop items that has dropTypes=undefined get special styling when another item is dragged 1`] = `
<SingleDropInner
className="domDragDrop domDragDrop-isDroppable domDragDrop-isNotDroppable"
data-test-subj="testDragDrop"
onDragEnter={[Function]}
onDragLeave={[Function]}
onDragOver={[Function]}
onDrop={[Function]}
>
<button
className="domDragDrop domDragDrop-isDroppable domDragDrop-isNotDroppable"
data-test-subj="testDragDrop"
onDragEnter={[Function]}
onDragLeave={[Function]}
onDragOver={[Function]}
onDrop={[Function]}
>
Hello!
</button>
</SingleDropInner>
`;
exports[`DragDrop renders if nothing is being dragged 1`] = `
<div
class=""
data-test-subj="domDragDrop_draggable-hello"
>
<button
aria-describedby="domDragDrop-keyboardInstructions"
aria-label="hello"
class="domDragDrop__keyboardHandler emotion-euiScreenReaderOnly"
data-test-subj="domDragDrop-keyboardHandler"
/>
<button
class="domDragDrop domDragDrop-isDraggable"
data-test-subj="domDragDrop"
draggable="true"
>
Hello!
</button>
</div>
`;

File diff suppressed because it is too large Load diff

View file

@ -1,947 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useContext, useCallback, useEffect, memo, useMemo, useState, useRef } from 'react';
import type { KeyboardEvent, ReactElement } from 'react';
import classNames from 'classnames';
import { keys, EuiScreenReaderOnly, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect';
import {
DragDropIdentifier,
DropIdentifier,
nextValidDropTarget,
ReorderContext,
DropHandler,
Ghost,
RegisteredDropTargets,
DragDropAction,
DragContextState,
useDragDropContext,
} from './providers';
import { DropType } from './types';
import { REORDER_ITEM_MARGIN } from './constants';
import './sass/drag_drop.scss';
/**
* Droppable event
*/
export type DroppableEvent = React.DragEvent<HTMLElement>;
const noop = () => {};
/**
* The base props to the DragDrop component.
*/
interface BaseProps {
/**
* The CSS class(es) for the root element.
*/
className?: string;
/**
* CSS class to apply when the item is being dragged
*/
dragClassName?: string;
/**
* The event handler that fires when an item
* is dropped onto this DragDrop component.
*/
onDrop?: DropHandler;
/**
* The event handler that fires when this element is dragged.
*/
onDragStart?: (
target?: DroppableEvent['currentTarget'] | KeyboardEvent<HTMLButtonElement>['currentTarget']
) => void;
/**
* The event handler that fires when the dragging of this element ends.
*/
onDragEnd?: () => void;
/**
* The value associated with this item.
*/
value: DragDropIdentifier;
/**
* The React element which will be passed the draggable handlers
*/
children: ReactElement;
/**
* Disable any drag & drop behaviour
*/
isDisabled?: boolean;
/**
* Indicates whether or not this component is draggable.
*/
draggable?: boolean;
/**
* Additional class names to apply when another element is over the drop target
*/
getAdditionalClassesOnEnter?: (dropType?: DropType) => string | undefined;
/**
* Additional class names to apply when another element is droppable for a currently dragged item
*/
getAdditionalClassesOnDroppable?: (dropType?: DropType) => string | undefined;
/**
* The optional test subject associated with this DOM element.
*/
dataTestSubj?: string;
/**
* items belonging to the same group that can be reordered
*/
reorderableGroup?: Array<{ id: string }>;
/**
* Indicates to the user whether the currently dragged item
* will be moved or copied
*/
dragType?: 'copy' | 'move';
/**
* Indicates the type of drop targets - when undefined, the currently dragged item
* cannot be dropped onto this component.
*/
dropTypes?: DropType[];
/**
* Order for keyboard dragging. This takes an array of numbers which will be used to order hierarchically
*/
order: number[];
/**
* Extra drop targets by dropType
*/
getCustomDropTarget?: (dropType: DropType) => ReactElement | null;
}
/**
* The props for a draggable instance of that component.
*/
interface DragInnerProps extends BaseProps {
dndDispatch: React.Dispatch<DragDropAction>;
dataTestSubjPrefix?: string;
activeDraggingProps?: {
keyboardMode: boolean;
activeDropTarget?: DropIdentifier;
dropTargetsByOrder: RegisteredDropTargets;
};
extraKeyboardHandler?: (e: KeyboardEvent<HTMLButtonElement>) => void;
ariaDescribedBy?: string;
}
/**
* The props for a non-draggable instance of that component.
*/
interface DropsInnerProps extends BaseProps {
dndState: DragContextState;
dndDispatch: React.Dispatch<DragDropAction>;
isNotDroppable: boolean;
}
const REORDER_OFFSET = REORDER_ITEM_MARGIN / 2;
/**
* DragDrop component
* @param props
* @constructor
*/
export const DragDrop = (props: BaseProps) => {
const [dndState, dndDispatch] = useDragDropContext();
const { dragging, dropTargetsByOrder } = dndState;
if (props.isDisabled) {
return props.children;
}
const { value, draggable, dropTypes, reorderableGroup } = props;
const isDragging = !!(draggable && value.id === dragging?.id);
const activeDraggingProps = isDragging
? {
keyboardMode: dndState.keyboardMode,
activeDropTarget: dndState.activeDropTarget,
dropTargetsByOrder,
}
: undefined;
if (draggable && (!dropTypes || !dropTypes.length)) {
const dragProps = {
...props,
activeDraggingProps,
dataTestSubjPrefix: dndState.dataTestSubjPrefix,
dndDispatch,
};
if (reorderableGroup && reorderableGroup.length > 1) {
return <ReorderableDrag {...dragProps} reorderableGroup={reorderableGroup} />;
} else {
return <DragInner {...dragProps} />;
}
}
const dropProps = {
...props,
dndState,
dndDispatch,
isNotDroppable:
// If the configuration has provided a droppable flag, but this particular item is not
// droppable, then it should be less prominent. Ignores items that are both
// draggable and drop targets
!!((!dropTypes || !dropTypes.length) && dragging && value.id !== dragging.id),
};
if (
reorderableGroup &&
reorderableGroup.length > 1 &&
reorderableGroup?.some((i) => i.id === dragging?.id) &&
dropTypes?.[0] === 'reorder'
) {
return <ReorderableDrop {...dropProps} reorderableGroup={reorderableGroup} />;
}
return <DropsInner {...dropProps} />;
};
const removeSelection = () => {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
};
const DragInner = memo(function DragInner({
dataTestSubj,
className,
dragClassName,
value,
children,
dndDispatch,
order,
activeDraggingProps,
dataTestSubjPrefix,
dragType,
onDragStart,
onDragEnd,
extraKeyboardHandler,
ariaDescribedBy,
}: DragInnerProps) {
const { keyboardMode, activeDropTarget, dropTargetsByOrder } = activeDraggingProps || {};
const setTarget = useCallback(
(target?: DropIdentifier) => {
if (!target) {
dndDispatch({
type: 'leaveDropTarget',
});
} else {
dndDispatch({
type: 'selectDropTarget',
payload: {
dropTarget: target,
dragging: value,
},
});
}
},
[dndDispatch, value]
);
const setTargetOfIndex = useCallback(
(id: string, index: number) => {
const dropTargetsForActiveId =
dropTargetsByOrder &&
Object.values(dropTargetsByOrder).filter((dropTarget) => dropTarget?.id === id);
setTarget(dropTargetsForActiveId?.[index]);
},
[dropTargetsByOrder, setTarget]
);
const modifierHandlers = useMemo(() => {
const onKeyUp = (e: KeyboardEvent<HTMLButtonElement>) => {
e.preventDefault();
if (activeDropTarget?.id && ['Shift', 'Alt', 'Control'].includes(e.key)) {
if (e.altKey) {
setTargetOfIndex(activeDropTarget.id, 1);
} else if (e.shiftKey) {
setTargetOfIndex(activeDropTarget.id, 2);
} else if (e.ctrlKey) {
// the control option is available either for new or existing cases,
// so need to offset based on some flags
const offsetIndex =
Number(activeDropTarget.humanData.canSwap) +
Number(activeDropTarget.humanData.canDuplicate);
setTargetOfIndex(activeDropTarget.id, offsetIndex + 1);
} else {
setTargetOfIndex(activeDropTarget.id, 0);
}
}
};
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
e.preventDefault();
if (e.key === 'Alt' && activeDropTarget?.id) {
setTargetOfIndex(activeDropTarget.id, 1);
} else if (e.key === 'Shift' && activeDropTarget?.id) {
setTargetOfIndex(activeDropTarget.id, 2);
} else if (e.key === 'Control' && activeDropTarget?.id) {
// the control option is available either for new or existing cases,
// so need to offset based on some flags
const offsetIndex =
Number(activeDropTarget.humanData.canSwap) +
Number(activeDropTarget.humanData.canDuplicate);
setTargetOfIndex(activeDropTarget.id, offsetIndex + 1);
}
};
return { onKeyDown, onKeyUp };
}, [activeDropTarget, setTargetOfIndex]);
const dragStart = useCallback(
(e: DroppableEvent | KeyboardEvent<HTMLButtonElement>, keyboardModeOn?: boolean) => {
// Setting stopPropgagation causes Chrome failures, so
// we are manually checking if we've already handled this
// in a nested child, and doing nothing if so...
if (e && 'dataTransfer' in e && e.dataTransfer.getData('text')) {
return;
}
// We only can reach the dragStart method if the element is draggable,
// so we know we have DraggableProps if we reach this code.
if (e && 'dataTransfer' in e) {
e.dataTransfer.setData('text', value.humanData.label);
// Apply an optional class to the element being dragged so the ghost
// can be styled. We must add it to the actual element for a single
// frame before removing it so the ghost picks up the styling.
const current = e.currentTarget;
if (dragClassName && !current.classList.contains(dragClassName)) {
current.classList.add(dragClassName);
requestAnimationFrame(() => {
current.classList.remove(dragClassName);
});
}
}
// Chrome causes issues if you try to render from within a
// dragStart event, so we drop a setTimeout to avoid that.
const currentTarget = e?.currentTarget;
setTimeout(() => {
dndDispatch({
type: 'startDragging',
payload: {
...(keyboardModeOn ? { keyboardMode: true } : {}),
dragging: {
...value,
ghost: keyboardModeOn
? {
children,
style: {
width: currentTarget.offsetWidth,
minHeight: currentTarget?.offsetHeight,
},
}
: undefined,
},
},
});
onDragStart?.(currentTarget);
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[dndDispatch, value, onDragStart]
);
const dragEnd = useCallback(
(e?: DroppableEvent) => {
e?.stopPropagation();
dndDispatch({
type: 'endDragging',
payload: { dragging: value },
});
onDragEnd?.();
},
[dndDispatch, value, onDragEnd]
);
const setNextTarget = (e: KeyboardEvent<HTMLButtonElement>, reversed = false) => {
const nextTarget = nextValidDropTarget(
dropTargetsByOrder,
activeDropTarget,
[order.join(',')],
(el) => el?.dropType !== 'reorder',
reversed
);
if (e.altKey && nextTarget?.id) {
setTargetOfIndex(nextTarget.id, 1);
} else if (e.shiftKey && nextTarget?.id) {
setTargetOfIndex(nextTarget.id, 2);
} else if (e.ctrlKey && nextTarget?.id) {
setTargetOfIndex(nextTarget.id, 3);
} else {
setTarget(nextTarget);
}
};
const dropToActiveDropTarget = () => {
if (activeDropTarget) {
const { dropType, onDrop } = activeDropTarget;
setTimeout(() => {
dndDispatch({
type: 'dropToTarget',
payload: {
dragging: value,
dropTarget: activeDropTarget,
},
});
});
onDrop(value, dropType);
}
};
const shouldShowGhostImageInstead =
dragType === 'move' &&
keyboardMode &&
activeDropTarget &&
activeDropTarget.dropType !== 'reorder';
return (
<div
className={classNames(className, {
'domDragDrop-isHidden':
(activeDraggingProps && dragType === 'move' && !keyboardMode) ||
shouldShowGhostImageInstead,
'domDragDrop--isDragStarted': activeDraggingProps,
})}
data-test-subj={`${dataTestSubjPrefix}_draggable-${value.humanData.label}`}
>
<EuiScreenReaderOnly showOnFocus>
<button
aria-label={value.humanData.label}
aria-describedby={ariaDescribedBy || `${dataTestSubjPrefix}-keyboardInstructions`}
className="domDragDrop__keyboardHandler"
data-test-subj={`${dataTestSubjPrefix}-keyboardHandler`}
onBlur={(e) => {
if (activeDraggingProps) {
dragEnd();
}
}}
onKeyDown={(e: KeyboardEvent<HTMLButtonElement>) => {
const { key } = e;
if (key === keys.ENTER || key === keys.SPACE) {
if (activeDropTarget) {
dropToActiveDropTarget();
}
if (activeDraggingProps) {
dragEnd();
} else {
dragStart(e, true);
}
} else if (key === keys.ESCAPE) {
if (activeDraggingProps) {
e.stopPropagation();
e.preventDefault();
dragEnd();
}
}
if (extraKeyboardHandler) {
extraKeyboardHandler(e);
}
if (keyboardMode) {
if (keys.ARROW_LEFT === key || keys.ARROW_RIGHT === key) {
setNextTarget(e, !!(keys.ARROW_LEFT === key));
}
modifierHandlers.onKeyDown(e);
}
}}
onKeyUp={modifierHandlers.onKeyUp}
/>
</EuiScreenReaderOnly>
{React.cloneElement(children, {
'data-test-subj': dataTestSubj || dataTestSubjPrefix,
className: classNames(children.props.className, 'domDragDrop', 'domDragDrop-isDraggable'),
draggable: true,
onDragEnd: dragEnd,
onDragStart: dragStart,
onMouseDown: removeSelection,
})}
</div>
);
});
const DropsInner = memo(function DropsInner(props: DropsInnerProps) {
const {
dataTestSubj,
className,
onDrop,
value,
children,
draggable,
dndState,
dndDispatch,
isNotDroppable,
dropTypes,
order,
getAdditionalClassesOnEnter,
getAdditionalClassesOnDroppable,
getCustomDropTarget,
} = props;
const { dragging, activeDropTarget, dataTestSubjPrefix, keyboardMode } = dndState;
const [isInZone, setIsInZone] = useState(false);
const mainTargetRef = useRef<HTMLDivElement>(null);
useShallowCompareEffect(() => {
if (dropTypes && dropTypes?.[0] && onDrop && keyboardMode) {
dndDispatch({
type: 'registerDropTargets',
payload: dropTypes.reduce(
(acc, dropType, index) => ({
...acc,
[[...props.order, index].join(',')]: { ...value, onDrop, dropType },
}),
{}
),
});
}
}, [order, dndDispatch, dropTypes, keyboardMode]);
useEffect(() => {
let isMounted = true;
if (activeDropTarget && activeDropTarget.id !== value.id) {
setIsInZone(false);
}
setTimeout(() => {
if (!activeDropTarget && isMounted) {
setIsInZone(false);
}
}, 1000);
return () => {
isMounted = false;
};
}, [activeDropTarget, setIsInZone, value.id]);
const dragEnter = () => {
if (!isInZone) {
setIsInZone(true);
}
};
const getModifiedDropType = (e: DroppableEvent, dropType: DropType) => {
if (!dropTypes || dropTypes.length <= 1) {
return dropType;
}
const dropIndex = dropTypes.indexOf(dropType);
if (dropIndex > 0) {
return dropType;
} else if (dropIndex === 0) {
if (e.altKey && dropTypes[1]) {
return dropTypes[1];
} else if (e.shiftKey && dropTypes[2]) {
return dropTypes[2];
} else if (e.ctrlKey && (dropTypes.length > 3 ? dropTypes[3] : dropTypes[1])) {
return dropTypes.length > 3 ? dropTypes[3] : dropTypes[1];
}
}
return dropType;
};
const dragOver = (e: DroppableEvent, dropType: DropType) => {
e.preventDefault();
if (!dragging || !onDrop) {
return;
}
const modifiedDropType = getModifiedDropType(e, dropType);
const isActiveDropTarget = !!(
activeDropTarget?.id === value.id && activeDropTarget?.dropType === modifiedDropType
);
// An optimization to prevent a bunch of React churn.
if (!isActiveDropTarget) {
dndDispatch({
type: 'selectDropTarget',
payload: {
dropTarget: { ...value, dropType: modifiedDropType, onDrop },
dragging,
},
});
}
};
const dragLeave = () => {
dndDispatch({ type: 'leaveDropTarget' });
};
const drop = (e: DroppableEvent, dropType: DropType) => {
e.preventDefault();
e.stopPropagation();
setIsInZone(false);
if (onDrop && dragging) {
const modifiedDropType = getModifiedDropType(e, dropType);
onDrop(dragging, modifiedDropType);
setTimeout(() => {
dndDispatch({
type: 'dropToTarget',
payload: {
dragging,
dropTarget: { ...value, dropType: modifiedDropType, onDrop },
},
});
});
}
dndDispatch({ type: 'resetState' });
};
const getProps = (dropType?: DropType, dropChildren?: ReactElement) => {
const isActiveDropTarget = Boolean(
activeDropTarget?.id === value.id && dropType === activeDropTarget?.dropType
);
return {
'data-test-subj': dataTestSubj || dataTestSubjPrefix,
className: getClasses(dropType, dropChildren),
onDragEnter: dragEnter,
onDragLeave: dragLeave,
onDragOver: dropType ? (e: DroppableEvent) => dragOver(e, dropType) : noop,
onDrop: dropType ? (e: DroppableEvent) => drop(e, dropType) : noop,
draggable,
ghost:
(isActiveDropTarget && dropType !== 'reorder' && dragging?.ghost && dragging.ghost) ||
undefined,
};
};
const getClasses = (dropType?: DropType, dropChildren = children) => {
const isActiveDropTarget = Boolean(
activeDropTarget?.id === value.id && dropType === activeDropTarget?.dropType
);
const classesOnDroppable = getAdditionalClassesOnDroppable?.(dropType);
const classes = classNames(
'domDragDrop',
{
'domDragDrop-isDraggable': draggable,
'domDragDrop-isDroppable': !draggable,
'domDragDrop-isDropTarget': dropType,
'domDragDrop-isActiveDropTarget': dropType && isActiveDropTarget,
'domDragDrop-isNotDroppable': isNotDroppable,
},
classesOnDroppable && { [classesOnDroppable]: dropType }
);
return classNames(classes, className, dropChildren.props.className);
};
const getMainTargetClasses = () => {
const classesOnEnter = getAdditionalClassesOnEnter?.(activeDropTarget?.dropType);
return classNames(classesOnEnter && { [classesOnEnter]: activeDropTarget?.id === value.id });
};
const mainTargetProps = getProps(dropTypes && dropTypes[0]);
return (
<div
data-test-subj={`${dataTestSubjPrefix}Container`}
className={classNames('domDragDrop__container', {
'domDragDrop__container-active': isInZone || activeDropTarget?.id === value.id,
})}
onDragEnter={dragEnter}
ref={mainTargetRef}
>
<SingleDropInner
{...mainTargetProps}
className={classNames(mainTargetProps.className, getMainTargetClasses())}
children={children}
/>
{dropTypes && dropTypes.length > 1 && (
<EuiFlexGroup
gutterSize="none"
direction="column"
data-test-subj={`${dataTestSubjPrefix}ExtraDrops`}
className={classNames('domDragDrop__extraDrops', {
'domDragDrop__extraDrops-visible': isInZone || activeDropTarget?.id === value.id,
})}
>
{dropTypes.slice(1).map((dropType) => {
const dropChildren = getCustomDropTarget?.(dropType);
return dropChildren ? (
<EuiFlexItem key={dropType} className="domDragDrop__extraDropWrapper">
<SingleDropInner {...getProps(dropType, dropChildren)}>
{dropChildren}
</SingleDropInner>
</EuiFlexItem>
) : null;
})}
</EuiFlexGroup>
)}
</div>
);
});
const SingleDropInner = ({
ghost,
children,
...rest
}: {
ghost?: Ghost;
children: ReactElement;
style?: React.CSSProperties;
className?: string;
}) => {
return (
<>
{React.cloneElement(children, rest)}
{ghost
? React.cloneElement(ghost.children, {
className: classNames(ghost.children.props.className, 'domDragDrop_ghost'),
style: ghost.style,
})
: null}
</>
);
};
const ReorderableDrag = memo(function ReorderableDrag(
props: DragInnerProps & { reorderableGroup: Array<{ id: string }>; dragging?: DragDropIdentifier }
) {
const [{ isReorderOn, reorderedItems, direction }, reorderDispatch] = useContext(ReorderContext);
const { value, activeDraggingProps, reorderableGroup, dndDispatch, dataTestSubjPrefix } = props;
const { keyboardMode, activeDropTarget, dropTargetsByOrder } = activeDraggingProps || {};
const isDragging = !!activeDraggingProps;
const isFocusInGroup = keyboardMode
? isDragging &&
(!activeDropTarget || reorderableGroup.some((i) => i.id === activeDropTarget?.id))
: isDragging;
useEffect(() => {
reorderDispatch({
type: 'setIsReorderOn',
payload: isFocusInGroup,
});
}, [reorderDispatch, isFocusInGroup]);
const onReorderableDragStart = (
currentTarget?:
| DroppableEvent['currentTarget']
| KeyboardEvent<HTMLButtonElement>['currentTarget']
) => {
if (currentTarget) {
setTimeout(() => {
reorderDispatch({
type: 'registerDraggingItemHeight',
payload: currentTarget.offsetHeight + REORDER_OFFSET,
});
});
}
};
const onReorderableDragEnd = () => {
reorderDispatch({ type: 'reset' });
};
const extraKeyboardHandler = (e: KeyboardEvent<HTMLButtonElement>) => {
if (isReorderOn && keyboardMode) {
e.stopPropagation();
e.preventDefault();
let activeDropTargetIndex = reorderableGroup.findIndex((i) => i.id === value.id);
if (activeDropTarget) {
const index = reorderableGroup.findIndex((i) => i.id === activeDropTarget?.id);
if (index !== -1) activeDropTargetIndex = index;
}
if (e.key === keys.ARROW_LEFT || e.key === keys.ARROW_RIGHT) {
reorderDispatch({ type: 'reset' });
} else if (keys.ARROW_DOWN === e.key) {
if (activeDropTargetIndex < reorderableGroup.length - 1) {
const nextTarget = nextValidDropTarget(
dropTargetsByOrder,
activeDropTarget,
[props.order.join(',')],
(el) => el?.dropType === 'reorder'
);
onReorderableDragOver(nextTarget);
}
} else if (keys.ARROW_UP === e.key) {
if (activeDropTargetIndex > 0) {
const nextTarget = nextValidDropTarget(
dropTargetsByOrder,
activeDropTarget,
[props.order.join(',')],
(el) => el?.dropType === 'reorder',
true
);
onReorderableDragOver(nextTarget);
}
}
}
};
const onReorderableDragOver = (target?: DropIdentifier) => {
if (!target) {
reorderDispatch({ type: 'reset' });
dndDispatch({ type: 'leaveDropTarget' });
return;
}
const droppingIndex = reorderableGroup.findIndex((i) => i.id === target.id);
const draggingIndex = reorderableGroup.findIndex((i) => i.id === value?.id);
if (draggingIndex === -1) {
return;
}
dndDispatch({
type: 'selectDropTarget',
payload: {
dropTarget: target,
dragging: value,
},
});
reorderDispatch({
type: 'setReorderedItems',
payload: { draggingIndex, droppingIndex, items: reorderableGroup },
});
};
const areItemsReordered = keyboardMode && isDragging && reorderedItems.length;
return (
<div
data-test-subj={`${dataTestSubjPrefix}-reorderableDrag`}
className={classNames('domDragDrop-reorderable', {
['domDragDrop-translatableDrag']: isDragging,
['domDragDrop-isKeyboardReorderInProgress']: keyboardMode && isDragging,
})}
style={
areItemsReordered
? {
transform: `translateY(${direction === '+' ? '-' : '+'}${reorderedItems.reduce(
(acc, el) => acc + (el.height ?? 0) + REORDER_OFFSET,
0
)}px)`,
}
: undefined
}
>
<DragInner
{...props}
ariaDescribedBy={`${dataTestSubjPrefix}-keyboardInstructionsWithReorder`}
extraKeyboardHandler={extraKeyboardHandler}
onDragStart={onReorderableDragStart}
onDragEnd={onReorderableDragEnd}
/>
</div>
);
});
const ReorderableDrop = memo(function ReorderableDrop(
props: DropsInnerProps & { reorderableGroup: Array<{ id: string }> }
) {
const { onDrop, value, dndState, dndDispatch, reorderableGroup } = props;
const { dragging, dataTestSubjPrefix, activeDropTarget } = dndState;
const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id);
const [{ isReorderOn, reorderedItems, draggingHeight, direction }, reorderDispatch] =
useContext(ReorderContext);
const heightRef = useRef<HTMLDivElement>(null);
const isReordered =
isReorderOn && reorderedItems.some((el) => el.id === value.id) && reorderedItems.length;
useEffect(() => {
if (isReordered && heightRef.current?.clientHeight) {
reorderDispatch({
type: 'registerReorderedItemHeight',
payload: { id: value.id, height: heightRef.current.clientHeight },
});
}
}, [isReordered, reorderDispatch, value.id]);
const onReorderableDragOver = (e: DroppableEvent) => {
e.preventDefault();
// An optimization to prevent a bunch of React churn.
if (activeDropTarget?.id !== value?.id && onDrop) {
const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id);
if (!dragging || draggingIndex === -1) {
return;
}
const droppingIndex = currentIndex;
if (draggingIndex === droppingIndex) {
reorderDispatch({ type: 'reset' });
}
reorderDispatch({
type: 'setReorderedItems',
payload: { draggingIndex, droppingIndex, items: reorderableGroup },
});
dndDispatch({
type: 'selectDropTarget',
payload: {
dropTarget: { ...value, dropType: 'reorder', onDrop },
dragging,
},
});
}
};
const onReorderableDrop = (e: DroppableEvent) => {
e.preventDefault();
e.stopPropagation();
if (onDrop && dragging) {
onDrop(dragging, 'reorder');
// setTimeout ensures it will run after dragEnd messaging
setTimeout(() => {
dndDispatch({
type: 'dropToTarget',
payload: {
dragging,
dropTarget: { ...value, dropType: 'reorder', onDrop },
},
});
});
}
dndDispatch({ type: 'resetState' });
};
return (
<div>
<div
style={
reorderedItems.some((i) => i.id === value.id)
? {
transform: `translateY(${direction}${draggingHeight}px)`,
}
: undefined
}
ref={heightRef}
data-test-subj={`${dataTestSubjPrefix}-translatableDrop`}
className="domDragDrop-translatableDrop domDragDrop-reorderable"
>
<DropsInner {...props} />
</div>
<div
data-test-subj={`${dataTestSubjPrefix}-reorderableDropLayer`}
className={classNames('domDragDrop', {
['domDragDrop__reorderableDrop']: dragging,
})}
onDrop={onReorderableDrop}
onDragOver={onReorderableDragOver}
onDragLeave={() => {
dndDispatch({ type: 'leaveDropTarget' });
reorderDispatch({ type: 'reset' });
}}
/>
</div>
);
});

View file

@ -0,0 +1,360 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { fireEvent, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Droppable, DroppableProps } from './droppable';
import { Draggable } from './draggable';
import { dataTransfer, generateDragDropValue, renderWithDragDropContext } from './test_utils';
import { ReorderProvider } from './providers/reorder_provider';
jest.useFakeTimers({ legacyFakeTimers: true });
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'clientHeight'
) || { value: 0 };
const expectLabel = (label: string) =>
expect.objectContaining({ humanData: expect.objectContaining({ label }) });
describe('Drag and drop reordering', () => {
const onDrop = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
type MaximumThreeDroppablesProps = [
Partial<DroppableProps>?,
Partial<DroppableProps>?,
Partial<DroppableProps>?
];
const renderDragAndDropGroup = (
propsOverrides: MaximumThreeDroppablesProps = [{}, {}, {}],
contextOverrides = {}
) => {
const values = propsOverrides.map((props, index) => {
return props?.value ? props.value : generateDragDropValue(`${index}`);
});
const reorderableGroup = values.map((value) => ({ id: value.id }));
const rtlRender = renderWithDragDropContext(
<>
<ReorderProvider dataTestSubj="domDragDrop">
{propsOverrides.map((props, index) => {
return (
<Draggable
key={index}
value={values[index]}
order={[index, 0]}
dragType="move"
reorderableGroup={reorderableGroup}
>
<Droppable
order={[index]}
onDrop={onDrop}
dropTypes={['reorder']}
{...props}
value={values[index]}
reorderableGroup={reorderableGroup}
>
<button>Element no{index}</button>
</Droppable>
</Draggable>
);
})}
</ReorderProvider>
<Droppable
order={[3, 0]}
onDrop={onDrop}
dropTypes={['duplicate_compatible']}
value={generateDragDropValue()}
reorderableGroup={reorderableGroup}
>
<button>Out of group</button>
</Droppable>
</>,
undefined,
contextOverrides
);
const droppables = screen.queryAllByTestId('domDragDrop-reorderableDropLayer');
const droppable = droppables[0];
const draggableKeyboardHandlers = screen.queryAllByTestId('domDragDrop-keyboardHandler');
const droppableContainers = screen.queryAllByTestId('domDragDropContainer');
return {
...rtlRender,
droppableContainer: droppableContainers[0],
startDragging: (index = 0) => {
const draggable = screen.getByTestId(`domDragDrop_domDraggable_${index}`);
fireEvent.dragStart(draggable, { dataTransfer });
act(() => {
jest.runAllTimers();
});
},
drop: (droppableIndex = 0, options = {}) => {
const dropEvent = new MouseEvent('drop', { ...options, bubbles: true });
fireEvent(droppables[droppableIndex], dropEvent);
act(() => {
jest.runAllTimers();
});
},
dragOver: (droppableIndex = 0, options = {}) => {
fireEvent.dragOver(droppables[droppableIndex], options);
act(() => {
jest.runAllTimers();
});
},
dragLeave: (droppableIndex = 0) => {
fireEvent.dragLeave(droppables[droppableIndex]);
act(() => {
jest.runAllTimers();
});
},
startDraggingByKeyboard: (index = 0) => {
draggableKeyboardHandlers[index].focus();
userEvent.keyboard('{enter}');
act(() => {
jest.runAllTimers();
});
},
dropByKeyboard: () => {
userEvent.keyboard('{enter}');
act(() => {
jest.runAllTimers();
});
},
cancelByKeyboard: () => {
userEvent.keyboard('{esc}');
act(() => {
jest.runAllTimers();
});
},
reorderDownByKeyboard: () => {
userEvent.keyboard('{arrowdown}');
act(() => {
jest.runAllTimers();
});
},
reorderUpByKeyboard: () => {
userEvent.keyboard('{arrowup}');
act(() => {
jest.runAllTimers();
});
},
dragOverToNextByKeyboard: () => {
userEvent.keyboard('{arrowright}');
act(() => {
jest.runAllTimers();
});
},
dragOverToPreviousByKeyboard: () => {
userEvent.keyboard('{arrowleft}');
act(() => {
jest.runAllTimers();
});
},
pressModifierKey: (key: '{Shift}' | '{Alt}' | '{Ctrl}') => {
userEvent.keyboard(key);
act(() => {
jest.runAllTimers();
});
},
droppable,
droppables,
};
};
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 40,
});
});
afterAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalOffsetHeight);
});
test('runs onDrop when the element is dropped', () => {
const { startDragging, drop } = renderDragAndDropGroup();
startDragging(0);
drop(1);
expect(onDrop).toBeCalled();
});
test('reordered elements get extra styling showing the new position', () => {
const { startDragging, dragOver } = renderDragAndDropGroup();
startDragging(0);
dragOver(1);
expect(screen.getAllByTestId('domDragDrop-translatableDrop')[1]).toHaveStyle({
transform: 'translateY(-48px)',
});
expect(screen.getAllByTestId('domDragDrop-translatableDrop')[2]).not.toHaveStyle({
transform: 'translateY(-48px)',
});
dragOver(2);
expect(screen.getAllByTestId('domDragDrop-translatableDrop')[1]).toHaveStyle({
transform: 'translateY(-48px)',
});
expect(screen.getAllByTestId('domDragDrop-translatableDrop')[2]).toHaveStyle({
transform: 'translateY(-48px)',
});
});
describe('keyboard mode', () => {
test('doesn`t run onDrop when dropping into an original position without any other movements', () => {
const { startDraggingByKeyboard, dropByKeyboard } = renderDragAndDropGroup();
// 0 -> 0
startDraggingByKeyboard(0);
dropByKeyboard();
expect(onDrop).not.toBeCalled();
});
test('doesn`t run onDrop when dropping into an original position after some movements', () => {
const {
startDraggingByKeyboard,
dropByKeyboard,
reorderDownByKeyboard,
reorderUpByKeyboard,
} = renderDragAndDropGroup();
// 1 -> 1
startDraggingByKeyboard(1);
reorderDownByKeyboard();
reorderUpByKeyboard();
dropByKeyboard();
expect(onDrop).not.toBeCalled();
});
test('doesnt run onDrop when the movement is cancelled', () => {
const { startDraggingByKeyboard, reorderDownByKeyboard, cancelByKeyboard } =
renderDragAndDropGroup();
// 1 -> x
startDraggingByKeyboard(0);
reorderDownByKeyboard();
reorderDownByKeyboard();
cancelByKeyboard();
expect(onDrop).not.toBeCalled();
});
test('runs onDrop when the element is reordered and dropped', () => {
const {
startDraggingByKeyboard,
dropByKeyboard,
reorderDownByKeyboard,
reorderUpByKeyboard,
} = renderDragAndDropGroup();
// 0--> 2
startDraggingByKeyboard(0);
reorderDownByKeyboard();
reorderDownByKeyboard();
dropByKeyboard();
expect(onDrop).toBeCalledWith(expectLabel('0'), 'reorder');
// 2 --> 0
startDraggingByKeyboard(2);
reorderUpByKeyboard();
reorderUpByKeyboard();
dropByKeyboard();
expect(onDrop).toBeCalledWith(expectLabel('2'), 'reorder');
});
test('reordered elements get extra styling showing the new position from element 0 to element 2', () => {
const { startDraggingByKeyboard, reorderDownByKeyboard } = renderDragAndDropGroup();
// 0--> 2
startDraggingByKeyboard(0);
reorderDownByKeyboard();
expect(screen.getAllByTestId('domDragDrop-reorderableDrag')[0]).toHaveStyle({
transform: 'translateY(+48px)',
});
expect(screen.getAllByTestId('domDragDrop-translatableDrop')[1]).toHaveStyle({
transform: 'translateY(-48px)',
});
expect(screen.getByText('Element no1')).toHaveClass('domDroppable--hover');
reorderDownByKeyboard();
expect(screen.getAllByTestId('domDragDrop-reorderableDrag')[0]).toHaveStyle({
transform: 'translateY(+96px)',
});
expect(screen.getAllByTestId('domDragDrop-translatableDrop')[1]).toHaveStyle({
transform: 'translateY(-48px)',
});
expect(screen.getAllByTestId('domDragDrop-translatableDrop')[2]).toHaveStyle({
transform: 'translateY(-48px)',
});
expect(screen.getByText('Element no2')).toHaveClass('domDroppable--hover');
});
test('reordered elements get extra styling showing the new position from element 2 to element 0', () => {
const { startDraggingByKeyboard, reorderUpByKeyboard } = renderDragAndDropGroup();
// 2 --> 0
startDraggingByKeyboard(2);
reorderUpByKeyboard();
expect(screen.getAllByTestId('domDragDrop-reorderableDrag')[2]).toHaveStyle({
transform: 'translateY(-48px)',
});
expect(screen.getAllByTestId('domDragDrop-translatableDrop')[1]).toHaveStyle({
transform: 'translateY(+48px)',
});
expect(screen.getByText('Element no1')).toHaveClass('domDroppable--hover');
reorderUpByKeyboard();
expect(screen.getAllByTestId('domDragDrop-reorderableDrag')[2]).toHaveStyle({
transform: 'translateY(-96px)',
});
expect(screen.getAllByTestId('domDragDrop-translatableDrop')[1]).toHaveStyle({
transform: 'translateY(+48px)',
});
expect(screen.getAllByTestId('domDragDrop-translatableDrop')[0]).toHaveStyle({
transform: 'translateY(+48px)',
});
expect(screen.getByText('Element no0')).toHaveClass('domDroppable--hover');
});
test('reorders through all the drop targets and then stops at the last element', () => {
const {
startDraggingByKeyboard,
reorderDownByKeyboard,
cancelByKeyboard,
reorderUpByKeyboard,
} = renderDragAndDropGroup();
startDraggingByKeyboard();
reorderDownByKeyboard();
reorderDownByKeyboard();
reorderDownByKeyboard();
reorderDownByKeyboard();
expect(screen.getByText('Element no2')).toHaveClass('domDroppable--hover');
cancelByKeyboard();
startDraggingByKeyboard(2);
reorderUpByKeyboard();
reorderUpByKeyboard();
reorderUpByKeyboard();
reorderUpByKeyboard();
expect(screen.getByText('Element no0')).toHaveClass('domDroppable--hover');
});
test('exits reordering and selects out of group target when hitting arrow left', () => {
const {
startDraggingByKeyboard,
cancelByKeyboard,
dragOverToPreviousByKeyboard,
dragOverToNextByKeyboard,
} = renderDragAndDropGroup();
startDraggingByKeyboard();
dragOverToNextByKeyboard();
expect(screen.getByText('Out of group')).toHaveClass('domDroppable--hover');
cancelByKeyboard();
startDraggingByKeyboard();
dragOverToPreviousByKeyboard();
expect(screen.getByText('Out of group')).toHaveClass('domDroppable--hover');
});
});
});

View file

@ -0,0 +1,137 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { fireEvent, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act } from 'react-dom/test-utils';
import { Draggable } from './draggable';
import { Droppable } from './droppable';
import {
generateDragDropValue,
renderWithDragDropContext,
dataTransfer,
EXACT,
} from './test_utils';
jest.useFakeTimers({ legacyFakeTimers: true });
describe('Draggable', () => {
const renderDraggable = (propsOverrides = {}) => {
const rtlRender = renderWithDragDropContext(
<>
<Draggable
dragType="move"
value={generateDragDropValue('drag_this')}
order={[2, 0, 1, 0]}
{...propsOverrides}
>
<button>Drag this</button>
</Draggable>
<Droppable
order={[2, 0, 1, 1]}
value={generateDragDropValue()}
onDrop={jest.fn()}
dropTypes={['field_replace']}
>
<button>Drop here</button>
</Droppable>
</>
);
const draggable = screen.getByTestId('domDragDrop_domDraggable_drag_this');
const draggableKeyboardHandler = screen.getByTestId('domDragDrop-keyboardHandler');
const droppable = screen.getByTestId('domDragDrop-domDroppable');
return {
...rtlRender,
draggable,
droppable,
startDragging: () => {
fireEvent.dragStart(draggable, { dataTransfer });
act(() => {
jest.runAllTimers();
});
},
startDraggingByKeyboard: () => {
draggableKeyboardHandler.focus();
userEvent.keyboard('{enter}');
act(() => {
jest.runAllTimers();
});
},
dragOverToNextByKeyboard: () => {
userEvent.keyboard('{arrowright}');
act(() => {
jest.runAllTimers();
});
},
endDragging: () => {
fireEvent.dragEnd(draggable, { dataTransfer });
act(() => {
jest.runAllTimers();
});
},
dragOver: () => {
fireEvent.dragOver(droppable);
act(() => {
jest.runAllTimers();
});
},
};
};
afterEach(() => {
jest.clearAllMocks();
});
test('makes component draggable', () => {
const { draggable } = renderDraggable();
expect(draggable).toHaveProperty('draggable', true);
});
test('removes selection on mouse down before dragging', async () => {
const removeAllRanges = jest.fn();
global.getSelection = jest.fn(() => ({ removeAllRanges } as unknown as Selection));
const { draggable } = renderDraggable();
fireEvent.mouseDown(draggable);
expect(global.getSelection).toBeCalled();
expect(removeAllRanges).toBeCalled();
});
test('on drag start, sets text in dataTransfer', async () => {
const { startDragging } = renderDraggable();
startDragging();
expect(dataTransfer.setData).toBeCalledWith('text', 'drag_this');
});
test('className is added when draggable is being dragged', async () => {
const { startDragging, draggable, endDragging } = renderDraggable({
dragClassName: 'dragTest',
});
expect(draggable).toHaveClass('domDraggable', EXACT);
startDragging();
expect(draggable).toHaveClass('domDraggable domDraggable_active--move', EXACT);
endDragging();
expect(draggable).toHaveClass('domDraggable', EXACT);
});
describe('keyboard mode', () => {
test('dragClassName is added to ghost when element is dragged', async () => {
const { startDraggingByKeyboard, dragOverToNextByKeyboard, droppable } = renderDraggable({
dragClassName: 'dragTest',
});
startDraggingByKeyboard();
dragOverToNextByKeyboard();
expect(droppable).toHaveClass('domDroppable domDroppable--active domDroppable--hover', EXACT);
expect(within(screen.getByTestId('domDragDropContainer')).getByText('Drag this')).toHaveClass(
'dragTest'
);
});
});
});

View file

@ -0,0 +1,545 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useContext, useCallback, useEffect, memo, useMemo } from 'react';
import type { KeyboardEvent, ReactElement } from 'react';
import classNames from 'classnames';
import { keys, EuiScreenReaderOnly } from '@elastic/eui';
import {
DragDropIdentifier,
DropIdentifier,
nextValidDropTarget,
ReorderContext,
RegisteredDropTargets,
DragDropAction,
useDragDropContext,
} from './providers';
import { REORDER_ITEM_MARGIN } from './constants';
import './sass/draggable.scss';
type DragEvent = React.DragEvent<HTMLElement>;
/**
* The base props to the Draggable component.
*/
interface DraggableProps {
/**
* The CSS class(es) for the root element.
*/
className?: string;
/**
* CSS class to apply when the item is being dragged
*/
dragClassName?: string;
/**
* The event handler that fires when this element is dragged.
*/
onDragStart?: (
target?: DragEvent['currentTarget'] | KeyboardEvent<HTMLButtonElement>['currentTarget']
) => void;
/**
* The event handler that fires when the dragging of this element ends.
*/
onDragEnd?: () => void;
/**
* The value associated with this item.
*/
value: DragDropIdentifier;
/**
* The React element which will be passed the draggable handlers
*/
children: ReactElement;
/**
* Disable any drag & drop behaviour
*/
isDisabled?: boolean;
/**
* The optional test subject associated with this DOM element.
*/
dataTestSubj?: string;
/**
* items belonging to the same group that can be reordered
*/
reorderableGroup?: Array<{ id: string }>;
/**
* Indicates to the user whether the currently dragged item
* will be moved or copied
*/
dragType: 'copy' | 'move';
/**
* Order for keyboard dragging. This takes an array of numbers which will be used to order hierarchically
*/
order: number[];
}
/**
* The props for a draggable instance of that component.
*/
interface DraggableImplProps extends DraggableProps {
dndDispatch: React.Dispatch<DragDropAction>;
dataTestSubjPrefix?: string;
draggedItemProps?: {
keyboardMode: boolean;
hoveredDropTarget?: DropIdentifier;
dropTargetsByOrder: RegisteredDropTargets;
};
extraKeyboardHandler?: (e: KeyboardEvent<HTMLButtonElement>) => void;
ariaDescribedBy?: string;
}
const REORDER_OFFSET = REORDER_ITEM_MARGIN / 2;
/**
* Draggable component
* @param props
* @constructor
*/
export const Draggable = ({ reorderableGroup, ...props }: DraggableProps) => {
const [
{ dragging, dropTargetsByOrder, hoveredDropTarget, keyboardMode, dataTestSubjPrefix },
dndDispatch,
] = useDragDropContext();
if (props.isDisabled) {
return props.children;
}
const isDragging = props.value.id === dragging?.id;
const draggableProps = {
...props,
draggedItemProps: isDragging
? {
keyboardMode,
hoveredDropTarget,
dropTargetsByOrder,
}
: undefined,
dataTestSubjPrefix,
dndDispatch,
};
if (reorderableGroup && reorderableGroup.length > 1) {
return <ReorderableDraggableImpl {...draggableProps} reorderableGroup={reorderableGroup} />;
} else {
return <DraggableImpl {...draggableProps} />;
}
};
const removeSelection = () => {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
};
const DraggableImpl = memo(function DraggableImpl({
dataTestSubj,
className,
dragClassName,
value,
children,
dndDispatch,
order,
draggedItemProps,
dataTestSubjPrefix,
dragType,
onDragStart,
onDragEnd,
extraKeyboardHandler,
ariaDescribedBy,
}: DraggableImplProps) {
const { keyboardMode, hoveredDropTarget, dropTargetsByOrder } = draggedItemProps || {};
const setTarget = useCallback(
(target?: DropIdentifier) => {
if (!target) {
dndDispatch({
type: 'leaveDropTarget',
});
} else {
dndDispatch({
type: 'selectDropTarget',
payload: {
dropTarget: target,
dragging: value,
},
});
}
},
[dndDispatch, value]
);
const setTargetOfIndex = useCallback(
(id: string, index: number) => {
const dropTargetsForActiveId =
dropTargetsByOrder &&
Object.values(dropTargetsByOrder).filter((dropTarget) => dropTarget?.id === id);
setTarget(dropTargetsForActiveId?.[index]);
},
[dropTargetsByOrder, setTarget]
);
const modifierHandlers = useMemo(() => {
const onKeyUp = (e: KeyboardEvent<HTMLButtonElement>) => {
e.preventDefault();
if (hoveredDropTarget?.id && ['Shift', 'Alt', 'Control'].includes(e.key)) {
if (e.altKey) {
setTargetOfIndex(hoveredDropTarget.id, 1);
} else if (e.shiftKey) {
setTargetOfIndex(hoveredDropTarget.id, 2);
} else if (e.ctrlKey) {
// the control option is available either for new or existing cases,
// so need to offset based on some flags
const offsetIndex =
Number(hoveredDropTarget.humanData.canSwap) +
Number(hoveredDropTarget.humanData.canDuplicate);
setTargetOfIndex(hoveredDropTarget.id, offsetIndex + 1);
} else {
setTargetOfIndex(hoveredDropTarget.id, 0);
}
}
};
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
e.preventDefault();
if (e.key === 'Alt' && hoveredDropTarget?.id) {
setTargetOfIndex(hoveredDropTarget.id, 1);
} else if (e.key === 'Shift' && hoveredDropTarget?.id) {
setTargetOfIndex(hoveredDropTarget.id, 2);
} else if (e.key === 'Control' && hoveredDropTarget?.id) {
// the control option is available either for new or existing cases,
// so need to offset based on some flags
const offsetIndex =
Number(hoveredDropTarget.humanData.canSwap) +
Number(hoveredDropTarget.humanData.canDuplicate);
setTargetOfIndex(hoveredDropTarget.id, offsetIndex + 1);
}
};
return { onKeyDown, onKeyUp };
}, [hoveredDropTarget, setTargetOfIndex]);
const dragStart = useCallback(
(e: DragEvent | KeyboardEvent<HTMLButtonElement>, keyboardModeOn?: boolean) => {
// Setting stopPropgagation causes Chrome failures, so
// we are manually checking if we've already handled this
// in a nested child, and doing nothing if so...
if (e && 'dataTransfer' in e && e.dataTransfer.getData('text')) {
return;
}
// We only can reach the dragStart method if the element is draggable,
// so we know we have DraggableProps if we reach this code.
if (e && 'dataTransfer' in e) {
e.dataTransfer.setData('text', value.humanData.label);
}
// Chrome causes issues if you try to render from within a
// dragStart event, so we drop a setTimeout to avoid that.
const currentTarget = e?.currentTarget;
onDragStart?.(e?.currentTarget);
// Apply an optional class to the element being dragged so the ghost
// can be styled. We must add it to the actual element for a single
// frame before removing it so the ghost picks up the styling.
const current = e.currentTarget;
if (dragClassName && !current.classList.contains(dragClassName)) {
current.classList.add(dragClassName);
requestAnimationFrame(() => {
current.classList.remove(dragClassName);
});
}
setTimeout(() => {
dndDispatch({
type: 'startDragging',
payload: {
...(keyboardModeOn ? { keyboardMode: true } : {}),
dragging: {
...value,
ghost: keyboardModeOn
? {
children,
className: classNames(dragClassName),
style: {
width: currentTarget.offsetWidth,
minHeight: currentTarget?.offsetHeight,
zIndex: 1000,
},
}
: undefined,
},
},
});
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[dndDispatch, value, onDragStart]
);
const dragEnd = useCallback(
(e?: DragEvent) => {
e?.stopPropagation();
dndDispatch({
type: 'endDragging',
payload: { dragging: value },
});
onDragEnd?.();
},
[dndDispatch, value, onDragEnd]
);
const setNextTarget = (e: KeyboardEvent<HTMLButtonElement>, reversed = false) => {
const nextTarget = nextValidDropTarget(
dropTargetsByOrder,
hoveredDropTarget,
[order.join(',')],
(el) => el?.dropType !== 'reorder',
reversed
);
if (typeof nextTarget === 'string' || nextTarget === undefined) {
return setTarget(undefined);
} else if (e.altKey) {
return setTargetOfIndex(nextTarget.id, 1);
} else if (e.shiftKey) {
return setTargetOfIndex(nextTarget.id, 2);
} else if (e.ctrlKey) {
return setTargetOfIndex(nextTarget.id, 3);
}
return setTarget(nextTarget);
};
const dropToSelectedDropTarget = () => {
if (hoveredDropTarget) {
const { dropType, onDrop } = hoveredDropTarget;
setTimeout(() => {
dndDispatch({
type: 'dropToTarget',
payload: {
dragging: value,
dropTarget: hoveredDropTarget,
},
});
});
onDrop(value, dropType);
}
};
const shouldShowGhostImageInstead =
dragType === 'move' &&
keyboardMode &&
hoveredDropTarget &&
hoveredDropTarget.dropType !== 'reorder';
return (
<div
className={classNames(className, 'domDraggable', {
'domDraggable_active--move': draggedItemProps && dragType === 'move' && !keyboardMode,
'domDraggable_dragover_keyboard--move': shouldShowGhostImageInstead,
'domDraggable_active--copy': draggedItemProps && dragType === 'copy' && !keyboardMode,
'domDraggable_dragover_keyboard--copy':
keyboardMode && draggedItemProps && hoveredDropTarget,
})}
data-test-subj={dataTestSubj || `${dataTestSubjPrefix}_domDraggable_${value.humanData.label}`}
draggable
onDragEnd={dragEnd}
onDragStart={dragStart}
onMouseDown={removeSelection}
>
<EuiScreenReaderOnly showOnFocus>
<button
aria-label={value.humanData.label}
aria-describedby={ariaDescribedBy || `${dataTestSubjPrefix}-keyboardInstructions`}
className="domDraggable__keyboardHandler"
data-test-subj={`${dataTestSubjPrefix}-keyboardHandler`}
onBlur={(e) => {
if (draggedItemProps) {
dragEnd();
}
}}
onKeyDown={(e: KeyboardEvent<HTMLButtonElement>) => {
const { key } = e;
if (key === keys.ENTER || key === keys.SPACE) {
if (hoveredDropTarget) {
dropToSelectedDropTarget();
}
if (draggedItemProps) {
dragEnd();
} else {
dragStart(e, true);
}
} else if (key === keys.ESCAPE) {
if (draggedItemProps) {
e.stopPropagation();
e.preventDefault();
dragEnd();
}
}
if (extraKeyboardHandler) {
extraKeyboardHandler(e);
}
if (keyboardMode) {
if (keys.ARROW_LEFT === key || keys.ARROW_RIGHT === key) {
setNextTarget(e, !!(keys.ARROW_LEFT === key));
}
modifierHandlers.onKeyDown(e);
}
}}
onKeyUp={modifierHandlers.onKeyUp}
/>
</EuiScreenReaderOnly>
{children}
</div>
);
});
const ReorderableDraggableImpl = memo(function ReorderableDraggableImpl(
props: DraggableImplProps & {
reorderableGroup: Array<{ id: string }>;
dragging?: DragDropIdentifier;
}
) {
const [{ isReorderOn, reorderedItems, direction }, reorderDispatch] = useContext(ReorderContext);
const { value, draggedItemProps, reorderableGroup, dndDispatch, dataTestSubjPrefix } = props;
const { keyboardMode, hoveredDropTarget, dropTargetsByOrder } = draggedItemProps || {};
const isDragging = !!draggedItemProps;
const isFocusInGroup = keyboardMode
? isDragging &&
(!hoveredDropTarget || reorderableGroup.some((i) => i.id === hoveredDropTarget?.id))
: isDragging;
useEffect(() => {
return () => reorderDispatch({ type: 'dragEnd' });
}, [reorderDispatch]);
useEffect(() => {
reorderDispatch({
type: 'setIsReorderOn',
payload: isFocusInGroup,
});
}, [reorderDispatch, isFocusInGroup]);
const onReorderableDragStart = (
currentTarget?: DragEvent['currentTarget'] | KeyboardEvent<HTMLButtonElement>['currentTarget']
) => {
if (currentTarget) {
setTimeout(() => {
reorderDispatch({
type: 'registerDraggingItemHeight',
payload: currentTarget.clientHeight + REORDER_OFFSET,
});
});
}
};
const onReorderableDragEnd = () => {
reorderDispatch({ type: 'dragEnd' });
};
const extraKeyboardHandler = (e: KeyboardEvent<HTMLButtonElement>) => {
if (isReorderOn && keyboardMode) {
e.stopPropagation();
e.preventDefault();
let activeDropTargetIndex = reorderableGroup.findIndex((i) => i.id === value.id);
if (hoveredDropTarget) {
const index = reorderableGroup.findIndex((i) => i.id === hoveredDropTarget?.id);
if (index !== -1) activeDropTargetIndex = index;
}
if (e.key === keys.ARROW_LEFT || e.key === keys.ARROW_RIGHT) {
reorderDispatch({ type: 'reset' });
} else if (keys.ARROW_DOWN === e.key) {
if (activeDropTargetIndex < reorderableGroup.length - 1) {
const nextTarget = nextValidDropTarget(
dropTargetsByOrder,
hoveredDropTarget,
[props.order.join(',')],
(el) => el?.dropType === 'reorder'
);
onReorderableDragOver(nextTarget);
}
} else if (keys.ARROW_UP === e.key) {
if (activeDropTargetIndex > 0) {
const nextTarget = nextValidDropTarget(
dropTargetsByOrder,
hoveredDropTarget,
[props.order.join(',')],
(el) => el?.dropType === 'reorder',
true
);
onReorderableDragOver(nextTarget);
}
}
}
};
const onReorderableDragOver = (target?: DropIdentifier) => {
if (!target) {
reorderDispatch({ type: 'reset' });
dndDispatch({ type: 'leaveDropTarget' });
return;
}
const droppingIndex = reorderableGroup.findIndex((i) => i.id === target.id);
const draggingIndex = reorderableGroup.findIndex((i) => i.id === value?.id);
if (draggingIndex === -1) {
return;
}
dndDispatch({
type: 'selectDropTarget',
payload: {
dropTarget: target,
dragging: value,
},
});
reorderDispatch({
type: 'setReorderedItems',
payload: { draggingIndex, droppingIndex, items: reorderableGroup },
});
};
const areItemsReordered = keyboardMode && isDragging && reorderedItems.length;
return (
<div
data-test-subj={`${dataTestSubjPrefix}-reorderableDrag`}
className={classNames({
['domDraggable--reorderable']: isDragging,
['domDraggable_active_keyboard--reorderable']: keyboardMode && isDragging,
})}
style={
areItemsReordered
? {
transform: `translateY(${direction === '+' ? '-' : '+'}${reorderedItems.reduce(
(acc, el) => acc + (el.height ?? 0) + REORDER_OFFSET,
0
)}px)`,
}
: undefined
}
>
<DraggableImpl
{...props}
ariaDescribedBy={`${dataTestSubjPrefix}-keyboardInstructionsWithReorder`}
extraKeyboardHandler={extraKeyboardHandler}
onDragStart={onReorderableDragStart}
onDragEnd={onReorderableDragEnd}
/>
</div>
);
});

View file

@ -35,15 +35,12 @@ export const DropOverlayWrapper: React.FC<DropOverlayWrapperProps> = ({
...otherProps
}) => {
return (
<div
className={classnames('domDragDrop__dropOverlayWrapper', className)}
{...(otherProps || {})}
>
<div className={classnames('domDroppable__overlayWrapper', className)} {...(otherProps || {})}>
{children}
{isVisible && (
<div
className="domDragDrop__dropOverlay"
data-test-subj="domDragDrop__dropOverlay"
className="domDroppable_overlay"
data-test-subj="domDroppable_overlay"
{...(overlayProps || {})}
/>
)}

View file

@ -51,7 +51,7 @@ function getPropsForDropType(type: 'swap' | 'duplicate' | 'combine') {
}
}
const getExtraDrop = ({
const getExtraTarget = ({
type,
isIncompatible,
}: {
@ -64,8 +64,8 @@ const getExtraDrop = ({
gutterSize="s"
justifyContent="spaceBetween"
alignItems="center"
className={classNames('domDragDrop__extraDrop', {
'domDragDrop-incompatibleExtraDrop': isIncompatible,
className={classNames('domDroppable__extraTarget', {
'domDroppable--incompatibleExtraTarget': isIncompatible,
})}
>
<EuiFlexItem grow={false}>
@ -88,15 +88,15 @@ const getExtraDrop = ({
};
const customDropTargetsMap: Partial<{ [dropType in DropType]: React.ReactElement }> = {
replace_duplicate_incompatible: getExtraDrop({ type: 'duplicate', isIncompatible: true }),
duplicate_incompatible: getExtraDrop({ type: 'duplicate', isIncompatible: true }),
swap_incompatible: getExtraDrop({ type: 'swap', isIncompatible: true }),
replace_duplicate_compatible: getExtraDrop({ type: 'duplicate' }),
duplicate_compatible: getExtraDrop({ type: 'duplicate' }),
swap_compatible: getExtraDrop({ type: 'swap' }),
field_combine: getExtraDrop({ type: 'combine' }),
combine_compatible: getExtraDrop({ type: 'combine' }),
combine_incompatible: getExtraDrop({ type: 'combine', isIncompatible: true }),
replace_duplicate_incompatible: getExtraTarget({ type: 'duplicate', isIncompatible: true }),
duplicate_incompatible: getExtraTarget({ type: 'duplicate', isIncompatible: true }),
swap_incompatible: getExtraTarget({ type: 'swap', isIncompatible: true }),
replace_duplicate_compatible: getExtraTarget({ type: 'duplicate' }),
duplicate_compatible: getExtraTarget({ type: 'duplicate' }),
swap_compatible: getExtraTarget({ type: 'swap' }),
field_combine: getExtraTarget({ type: 'combine' }),
combine_compatible: getExtraTarget({ type: 'combine' }),
combine_incompatible: getExtraTarget({ type: 'combine', isIncompatible: true }),
};
export const getCustomDropTarget = (dropType: DropType) => customDropTargetsMap?.[dropType] || null;
@ -112,7 +112,7 @@ export const getAdditionalClassesOnEnter = (dropType?: string) => {
'replace_duplicate_incompatible',
].includes(dropType)
) {
return 'domDragDrop-isReplacing';
return 'domDroppable--replacing';
}
};
@ -128,6 +128,6 @@ export const getAdditionalClassesOnDroppable = (dropType?: string) => {
'combine_incompatible',
].includes(dropType)
) {
return 'domDragDrop-notCompatible';
return 'domDroppable--incompatible';
}
};

View file

@ -0,0 +1,487 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { fireEvent, screen, act, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Droppable } from './droppable';
import { Draggable } from './draggable';
import {
dataTransfer,
generateDragDropValue,
renderWithDragDropContext,
EXACT,
} from './test_utils';
jest.useFakeTimers({ legacyFakeTimers: true });
const draggableValue = generateDragDropValue('drag_this');
describe('Droppable', () => {
const onDrop = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
const renderTestComponents = (propsOverrides = [{}]) => {
const rtlRender = renderWithDragDropContext(
<>
<Draggable dragType="move" value={draggableValue} order={[2, 0, 0, 0]}>
<button>Drag this</button>
</Draggable>
{propsOverrides.map((propOverrides, index) => (
<Droppable
key={index}
order={[2, 0, index + 2, 0]}
value={generateDragDropValue()}
onDrop={onDrop}
dropTypes={undefined}
{...propOverrides}
>
<button>Drop here</button>
</Droppable>
))}
</>
);
const draggable = screen.getByTestId('domDragDrop_domDraggable_drag_this');
const droppables = screen.queryAllByTestId('domDragDrop-domDroppable');
const droppable = droppables[0];
const draggableKeyboardHandler = screen.getByTestId('domDragDrop-keyboardHandler');
const droppableContainers = screen.queryAllByTestId('domDragDropContainer');
return {
...rtlRender,
draggable,
droppableContainer: droppableContainers[0],
startDragging: () => {
fireEvent.dragStart(draggable, { dataTransfer });
act(() => {
jest.runAllTimers();
});
},
drop: (droppableIndex = 0, options = {}) => {
const dropEvent = new MouseEvent('drop', { ...options, bubbles: true });
fireEvent(droppables[droppableIndex], dropEvent);
act(() => {
jest.runAllTimers();
});
},
dragOver: (droppableIndex = 0, options = {}) => {
// const dropEvent = new MouseEvent('dragOver', options);
// fireEvent(droppables[droppableIndex], dropEvent);
fireEvent.dragOver(droppables[droppableIndex], options);
act(() => {
jest.runAllTimers();
});
},
dragLeave: (droppableIndex = 0) => {
fireEvent.dragLeave(droppables[droppableIndex]);
act(() => {
jest.runAllTimers();
});
},
startDraggingByKeyboard: () => {
draggableKeyboardHandler.focus();
userEvent.keyboard('{enter}');
act(() => {
jest.runAllTimers();
});
},
dropByKeyboard: () => {
draggableKeyboardHandler.focus();
userEvent.keyboard('{enter}');
act(() => {
jest.runAllTimers();
});
},
dragOverToNextByKeyboard: () => {
userEvent.keyboard('{arrowright}');
act(() => {
jest.runAllTimers();
});
},
dragOverToPreviousByKeyboard: () => {
userEvent.keyboard('{arrowleft}');
act(() => {
jest.runAllTimers();
});
},
pressModifierKey: (key: '{Shift}' | '{Alt}' | '{Ctrl}') => {
userEvent.keyboard(key);
act(() => {
jest.runAllTimers();
});
},
droppable,
droppables,
};
};
test('receives additional classname when is active dropType', () => {
const { droppable, startDragging } = renderTestComponents([{ dropTypes: ['field_add'] }]);
expect(droppable).toHaveClass('domDroppable', EXACT);
startDragging();
expect(droppable).toHaveClass('domDroppable domDroppable--active', EXACT);
});
test('receives additional classname when is active dropType and has custom class', () => {
const { droppable, startDragging } = renderTestComponents([
{
dropTypes: ['field_add'],
getAdditionalClassesOnEnter: () => 'customClassOnEnter',
getAdditionalClassesOnDroppable: () => 'customClassOnActive',
},
]);
expect(droppable).toHaveClass('domDroppable', EXACT);
startDragging();
expect(droppable).toHaveClass('domDroppable domDroppable--active customClassOnActive', EXACT);
});
test('receives additional classname when is active dropType and has custom class on enter until dragleave', () => {
const { droppable, startDragging, dragOver, dragLeave } = renderTestComponents([
{
dropTypes: ['field_add'],
getAdditionalClassesOnEnter: () => 'customClassOnEnter',
getAdditionalClassesOnDroppable: () => 'customClassOnActive',
},
]);
expect(droppable).toHaveClass('domDroppable', EXACT);
startDragging();
dragOver();
expect(droppable).toHaveClass(
'domDroppable domDroppable--active domDroppable--hover customClassOnActive customClassOnEnter',
EXACT
);
dragLeave();
expect(droppable).toHaveClass('domDroppable domDroppable--active customClassOnActive', EXACT);
});
test('receives additional classname when is active dropType and has custom class on enter until drop', () => {
const { droppable, startDragging, dragOver, drop } = renderTestComponents([
{
dropTypes: ['field_add'],
getAdditionalClassesOnEnter: () => 'customClassOnEnter',
getAdditionalClassesOnDroppable: () => 'customClassOnActive',
},
]);
expect(droppable).toHaveClass('domDroppable', EXACT);
startDragging();
dragOver();
expect(droppable).toHaveClass(
'domDroppable domDroppable--active domDroppable--hover customClassOnActive customClassOnEnter',
EXACT
);
drop();
expect(droppable).toHaveClass('domDroppable', EXACT);
});
test('gets special styling when another item is dragged if droppable doesnt have dropTypes', () => {
const { droppable, startDragging } = renderTestComponents();
startDragging();
expect(droppable).toHaveClass('domDroppable domDroppable--notAllowed', EXACT);
});
test('drop function is not called on dropTypes undefined', async () => {
const { drop, startDragging } = renderTestComponents();
startDragging();
drop();
expect(onDrop).not.toHaveBeenCalled();
});
test('onDrop callback is executed when dropping', async () => {
const { startDragging, drop } = renderTestComponents([
{
dropTypes: ['field_add'],
onDrop,
},
]);
startDragging();
drop();
expect(onDrop).toBeCalledWith(
expect.objectContaining({ humanData: expect.objectContaining({ label: 'drag_this' }) }),
'field_add'
);
});
describe('keyboard mode', () => {
test('drop targets get highlighted when pressing arrow keys and draggable get action class too', () => {
const {
droppables,
startDraggingByKeyboard,
dropByKeyboard,
dragOverToNextByKeyboard,
draggable,
} = renderTestComponents([{ dropTypes: ['field_add'] }, { dropTypes: ['field_add'] }]);
startDraggingByKeyboard();
expect(draggable).toHaveClass('domDraggable', EXACT);
expect(droppables[0]).toHaveClass('domDroppable domDroppable--active', EXACT);
expect(droppables[1]).toHaveClass('domDroppable domDroppable--active', EXACT);
dragOverToNextByKeyboard();
expect(draggable).toHaveClass(
'domDraggable domDraggable_dragover_keyboard--move domDraggable_dragover_keyboard--copy',
EXACT
);
expect(droppables[0]).toHaveClass(
'domDroppable domDroppable--active domDroppable--hover',
EXACT
);
expect(droppables[1]).toHaveClass('domDroppable domDroppable--active', EXACT);
dragOverToNextByKeyboard();
expect(droppables[0]).toHaveClass('domDroppable domDroppable--active', EXACT);
expect(droppables[1]).toHaveClass(
'domDroppable domDroppable--active domDroppable--hover',
EXACT
);
dropByKeyboard();
expect(draggable).toHaveClass('domDraggable', EXACT);
expect(droppables[0]).toHaveClass('domDroppable', EXACT);
expect(droppables[1]).toHaveClass('domDroppable', EXACT);
});
test('executes onDrop callback when drops on drop target', () => {
const firstDroppableOnDrop = jest.fn();
const secondDroppableOnDrop = jest.fn();
const { startDraggingByKeyboard, dropByKeyboard, dragOverToNextByKeyboard } =
renderTestComponents([
{ dropTypes: ['field_add'], onDrop: firstDroppableOnDrop },
{ dropTypes: ['field_add'], onDrop: secondDroppableOnDrop },
]);
startDraggingByKeyboard();
// goes to first target
dragOverToNextByKeyboard();
// goes to second target
dragOverToNextByKeyboard();
// drops on second target
dropByKeyboard();
expect(firstDroppableOnDrop).not.toBeCalled();
expect(secondDroppableOnDrop).toHaveBeenCalledWith(draggableValue, 'field_add');
});
test('adds ghost to droppable when element is dragged over', async () => {
const { startDraggingByKeyboard, droppables, draggable, dragOverToNextByKeyboard } =
renderTestComponents([{ dropTypes: ['field_add'] }, { dropTypes: ['field_add'] }]);
startDraggingByKeyboard();
dragOverToNextByKeyboard();
expect(droppables[0]).toHaveClass(
'domDroppable domDroppable--active domDroppable--hover',
EXACT
);
const domDragDropContainers = screen.queryAllByTestId('domDragDropContainer');
const ghostElement = within(domDragDropContainers[0]).getByText('Drag this');
expect(ghostElement).toHaveClass('domDraggable_ghost', EXACT);
expect(ghostElement.textContent).toEqual(draggable.textContent);
});
});
describe('multiple drop targets', () => {
test('renders extra drop targets', () => {
const { droppables } = renderTestComponents([
{
dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'],
getCustomDropTarget: (dropType: string) => <div className="extraDrop">{dropType}</div>,
},
]);
expect(droppables).toHaveLength(3);
});
test('extra drop targets appear when dragging over and disappear when hoveredDropTarget changes', () => {
const { dragLeave, dragOver, startDragging, droppableContainer } = renderTestComponents([
{
dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'],
getCustomDropTarget: (dropType: string) => <div className="extraDrop">{dropType}</div>,
},
]);
startDragging();
expect(droppableContainer).toHaveClass('domDroppable__container', { exact: true });
dragOver();
expect(droppableContainer).toHaveClass(
'domDroppable__container domDroppable__container-active',
{ exact: true }
);
expect(screen.queryAllByTestId('domDragDropExtraTargets')[0]).toHaveClass(
'domDroppable__extraTargets-visible'
);
dragLeave();
expect(screen.queryAllByTestId('domDragDropExtraTargets')[0]).not.toHaveClass(
'domDroppable__extraTargets-visible'
);
});
test('correct dropTarget is highlighted within drop targets with the same value and different dropTypes', () => {
const { startDragging, dragOver, droppables, dragLeave } = renderTestComponents([
{
dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'],
getCustomDropTarget: (dropType: string) => <div className="extraDrop">{dropType}</div>,
},
]);
startDragging();
dragOver(1);
expect(droppables[0]).toHaveClass('domDroppable domDroppable--active', EXACT);
expect(droppables[1]).toHaveClass(
'domDroppable domDroppable--active domDroppable--hover extraDrop',
EXACT
);
expect(droppables[2]).toHaveClass('domDroppable domDroppable--active extraDrop', EXACT);
dragLeave(1);
expect(droppables[0]).toHaveClass('domDroppable domDroppable--active', EXACT);
expect(droppables[1]).toHaveClass('domDroppable domDroppable--active extraDrop', EXACT);
expect(droppables[2]).toHaveClass('domDroppable domDroppable--active extraDrop', EXACT);
});
test('onDrop callback is executed when dropping on extra drop target', () => {
const { startDragging, dragOver, drop } = renderTestComponents([
{
dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'],
onDrop,
getCustomDropTarget: (dropType: string) => <div className="extraDrop">{dropType}</div>,
},
]);
startDragging();
dragOver();
drop();
expect(onDrop).toBeCalledWith(draggableValue, 'move_compatible');
startDragging();
dragOver(1);
drop(1);
expect(onDrop).toBeCalledWith(draggableValue, 'duplicate_compatible');
startDragging();
dragOver(2);
drop(2);
expect(onDrop).toBeCalledWith(draggableValue, 'swap_compatible');
});
test('pressing Alt or Shift when dragging over the main drop target sets extra drop target as active', () => {
const { startDragging, dragOver, drop } = renderTestComponents([
{
dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'],
onDrop,
getCustomDropTarget: (dropType: string) => <div className="extraDrop">{dropType}</div>,
},
]);
startDragging();
dragOver(0, { altKey: true });
drop(0, { altKey: true });
expect(onDrop).toBeCalledWith(draggableValue, 'duplicate_compatible');
startDragging();
dragOver(0, { shiftKey: true });
drop(0, { shiftKey: true });
expect(onDrop).toBeCalledWith(draggableValue, 'swap_compatible');
});
test('pressing Alt or Shift when dragging over the extra drop target does nothing', () => {
const { startDragging, dragOver, drop } = renderTestComponents([
{
dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'],
onDrop,
getCustomDropTarget: (dropType: string) => <div className="extraDrop">{dropType}</div>,
},
]);
startDragging();
dragOver(1, { shiftKey: true });
drop(1, { shiftKey: true });
expect(onDrop).toBeCalledWith(draggableValue, 'duplicate_compatible');
});
describe('keyboard mode', () => {
test('user can go through all the drop targets ', () => {
const { startDraggingByKeyboard, dragOverToNextByKeyboard, droppables, pressModifierKey } =
renderTestComponents([
{
dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'],
getCustomDropTarget: (dropType: string) => (
<div className="extraDrop">{dropType}</div>
),
},
{
dropTypes: ['move_compatible'],
getCustomDropTarget: (dropType: string) => (
<div className="extraDrop">{dropType}</div>
),
},
]);
startDraggingByKeyboard();
dragOverToNextByKeyboard();
expect(droppables[0]).toHaveClass('domDroppable--hover');
pressModifierKey('{Alt}');
expect(droppables[1]).toHaveClass('domDroppable--hover');
pressModifierKey('{Shift}');
expect(droppables[2]).toHaveClass('domDroppable--hover');
dragOverToNextByKeyboard();
expect(droppables[3]).toHaveClass('domDroppable--hover');
dragOverToNextByKeyboard();
// we circled back to the draggable (no drop target is selected)
dragOverToNextByKeyboard();
expect(droppables[0]).toHaveClass('domDroppable--hover');
});
test('user can go through all the drop targets in reverse direction', () => {
const {
startDraggingByKeyboard,
dragOverToPreviousByKeyboard,
droppables,
pressModifierKey,
} = renderTestComponents([
{
dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'],
getCustomDropTarget: (dropType: string) => <div className="extraDrop">{dropType}</div>,
},
{
dropTypes: ['move_compatible'],
getCustomDropTarget: (dropType: string) => <div className="extraDrop">{dropType}</div>,
},
]);
startDraggingByKeyboard();
dragOverToPreviousByKeyboard();
expect(droppables[3]).toHaveClass('domDroppable--hover');
dragOverToPreviousByKeyboard();
expect(droppables[0]).toHaveClass('domDroppable--hover');
pressModifierKey('{Alt}');
expect(droppables[1]).toHaveClass('domDroppable--hover');
pressModifierKey('{Shift}');
expect(droppables[2]).toHaveClass('domDroppable--hover');
dragOverToPreviousByKeyboard();
// we circled back to the draggable (no drop target is selected)
dragOverToPreviousByKeyboard();
expect(droppables[3]).toHaveClass('domDroppable--hover');
});
test('user can drop on extra drop targets', () => {
const {
startDraggingByKeyboard,
dragOverToNextByKeyboard,
dropByKeyboard,
pressModifierKey,
} = renderTestComponents([
{
dropTypes: ['move_compatible', 'duplicate_compatible', 'swap_compatible'],
onDrop,
getCustomDropTarget: (dropType: string) => <div className="extraDrop">{dropType}</div>,
},
]);
startDraggingByKeyboard();
dragOverToNextByKeyboard();
dropByKeyboard();
expect(onDrop).toHaveBeenCalledWith(draggableValue, 'move_compatible');
onDrop.mockClear();
startDraggingByKeyboard();
dragOverToNextByKeyboard();
pressModifierKey('{Alt}');
dropByKeyboard();
expect(onDrop).toHaveBeenCalledWith(draggableValue, 'duplicate_compatible');
onDrop.mockClear();
startDraggingByKeyboard();
dragOverToNextByKeyboard();
pressModifierKey('{Shift}');
dropByKeyboard();
expect(onDrop).toHaveBeenCalledWith(draggableValue, 'swap_compatible');
});
});
});
});

View file

@ -0,0 +1,481 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useContext, useCallback, useEffect, memo, useState, useRef } from 'react';
import type { ReactElement } from 'react';
import classNames from 'classnames';
import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect';
import {
DragDropIdentifier,
ReorderContext,
DropHandler,
Ghost,
DragDropAction,
DragContextState,
useDragDropContext,
} from './providers';
import { DropType } from './types';
import './sass/droppable.scss';
type DroppableEvent = React.DragEvent<HTMLElement>;
const noop = () => {};
/**
* The base props to the Droppable component.
*/
export interface DroppableProps {
/**
* The CSS class(es) for the root element.
*/
className?: string;
/**
* The event handler that fires when an item
* is dropped onto this Droppable component.
*/
onDrop?: DropHandler;
/**
* The value associated with this item.
*/
value: DragDropIdentifier;
/**
* The React element which will be passed the draggable handlers
*/
children: ReactElement;
/**
* Disable any drag & drop behaviour
*/
isDisabled?: boolean;
/**
* Additional class names to apply when another element is over the drop target
*/
getAdditionalClassesOnEnter?: (dropType?: DropType) => string | undefined;
/**
* Additional class names to apply when another element is droppable for a currently dragged item
*/
getAdditionalClassesOnDroppable?: (dropType?: DropType) => string | undefined;
/**
* The optional test subject associated with this DOM element.
*/
dataTestSubj?: string;
/**
* items belonging to the same group that can be reordered
*/
reorderableGroup?: Array<{ id: string }>;
/**
* Indicates the type of drop targets - when undefined, the currently dragged item
* cannot be dropped onto this component.
*/
dropTypes?: DropType[];
/**
* Order for keyboard dragging. This takes an array of numbers which will be used to order hierarchically
*/
order: number[];
/**
* Extra drop targets by dropType
*/
getCustomDropTarget?: (dropType: DropType) => ReactElement | null;
}
/**
* The props for a non-draggable instance of that component.
*/
interface DropsInnerProps extends DroppableProps {
dndState: DragContextState;
dndDispatch: React.Dispatch<DragDropAction>;
}
/**
* Droppable component
* @param props
* @constructor
*/
export const Droppable = (props: DroppableProps) => {
const [dndState, dndDispatch] = useDragDropContext();
if (props.isDisabled) {
return props.children;
}
const { dropTypes, reorderableGroup } = props;
const dropProps = {
...props,
dndState,
dndDispatch,
};
if (reorderableGroup && reorderableGroup.length > 1 && dropTypes?.[0] === 'reorder') {
return <ReorderableDroppableImpl {...dropProps} reorderableGroup={reorderableGroup} />;
}
return <DroppableImpl {...dropProps} />;
};
const DroppableImpl = memo(function DroppableImpl(props: DropsInnerProps) {
const {
dataTestSubj,
className,
onDrop,
value,
children,
dndState,
dndDispatch,
dropTypes,
order,
getAdditionalClassesOnEnter,
getAdditionalClassesOnDroppable,
getCustomDropTarget,
} = props;
const { dragging, hoveredDropTarget, dataTestSubjPrefix, keyboardMode } = dndState;
const [isInZone, setIsInZone] = useState(false);
const mainTargetRef = useRef<HTMLDivElement>(null);
useShallowCompareEffect(() => {
if (dropTypes && dropTypes?.[0] && onDrop && keyboardMode) {
dndDispatch({
type: 'registerDropTargets',
payload: dropTypes.reduce(
(acc, dropType, index) => ({
...acc,
[[...order, index].join(',')]: { ...value, onDrop, dropType },
}),
{}
),
});
}
}, [order, dndDispatch, dropTypes, keyboardMode]);
useEffect(() => {
let isMounted = true;
if (hoveredDropTarget && hoveredDropTarget.id !== value.id) {
setIsInZone(false);
}
setTimeout(() => {
if (!hoveredDropTarget && isMounted) {
setIsInZone(false);
}
}, 1000);
return () => {
isMounted = false;
};
}, [hoveredDropTarget, setIsInZone, value.id]);
const dragEnter = useCallback(() => {
if (!isInZone) {
setIsInZone(true);
}
}, [isInZone]);
const getModifiedDropType = useCallback(
(e: DroppableEvent, dropType: DropType) => {
if (!dropTypes || dropTypes.length <= 1) {
return dropType;
}
const dropIndex = dropTypes.indexOf(dropType);
if (dropIndex > 0) {
return dropType;
} else if (dropIndex === 0) {
if (e.altKey && dropTypes[1]) {
return dropTypes[1];
} else if (e.shiftKey && dropTypes[2]) {
return dropTypes[2];
} else if (e.ctrlKey && (dropTypes.length > 3 ? dropTypes[3] : dropTypes[1])) {
return dropTypes.length > 3 ? dropTypes[3] : dropTypes[1];
}
}
return dropType;
},
[dropTypes]
);
const dragOver = (e: DroppableEvent, dropType: DropType) => {
e.preventDefault();
if (!dragging || !onDrop) {
return;
}
const modifiedDropType = getModifiedDropType(e, dropType);
const isSelectedDropTarget = !!(
hoveredDropTarget?.id === value.id && hoveredDropTarget?.dropType === modifiedDropType
);
// An optimization to prevent a bunch of React churn.
if (!isSelectedDropTarget) {
dndDispatch({
type: 'selectDropTarget',
payload: {
dropTarget: { ...value, dropType: modifiedDropType, onDrop },
dragging,
},
});
}
};
const dragLeave = useCallback(() => {
dndDispatch({ type: 'leaveDropTarget' });
}, [dndDispatch]);
const drop = useCallback(
(e: DroppableEvent, dropType: DropType) => {
e.preventDefault();
e.stopPropagation();
setIsInZone(false);
if (onDrop && dragging) {
const modifiedDropType = getModifiedDropType(e, dropType);
onDrop(dragging, modifiedDropType);
setTimeout(() => {
dndDispatch({
type: 'dropToTarget',
payload: {
dragging,
dropTarget: { ...value, dropType: modifiedDropType, onDrop },
},
});
});
}
dndDispatch({ type: 'resetState' });
},
[dndDispatch, onDrop, dragging, getModifiedDropType, value]
);
const getProps = (dropType?: DropType, dropChildren?: ReactElement) => {
const isSelectedDropTarget = Boolean(
hoveredDropTarget?.id === value.id && dropType === hoveredDropTarget?.dropType
);
return {
'data-test-subj': dataTestSubj || `${dataTestSubjPrefix}-domDroppable`,
className: getClasses(dropType, dropChildren),
onDragEnter: dragEnter,
onDragLeave: dragLeave,
onDragOver: dropType
? (e: DroppableEvent) => {
dragOver(e, dropType);
}
: noop,
onDrop: dropType
? (e: DroppableEvent) => {
drop(e, dropType);
}
: noop,
ghost:
(isSelectedDropTarget && dropType !== 'reorder' && dragging?.ghost && dragging.ghost) ||
undefined,
};
};
const getClasses = (dropType?: DropType, dropChildren = children) => {
const isSelectedDropTarget = Boolean(
hoveredDropTarget?.id === value.id && dropType === hoveredDropTarget?.dropType
);
const classesOnDroppable = getAdditionalClassesOnDroppable?.(dropType);
const classes = classNames(
'domDroppable',
{
'domDroppable--active': dragging && dropType,
'domDroppable--hover': dropType && isSelectedDropTarget,
'domDroppable--notAllowed':
dragging && (!dropTypes || !dropTypes.length) && value.id !== dragging.id,
},
classesOnDroppable && { [classesOnDroppable]: dragging && dropType }
);
return classNames(classes, className, dropChildren.props.className);
};
const getMainTargetClasses = () => {
const classesOnEnter = getAdditionalClassesOnEnter?.(hoveredDropTarget?.dropType);
return classNames(classesOnEnter && { [classesOnEnter]: hoveredDropTarget?.id === value.id });
};
const mainTargetProps = getProps(dropTypes && dropTypes[0]);
return (
<div
data-test-subj={`${dataTestSubjPrefix}Container`}
className={classNames('domDroppable__container', {
'domDroppable__container-active': isInZone || hoveredDropTarget?.id === value.id,
})}
onDragEnter={dragEnter}
ref={mainTargetRef}
>
<SingleDropInner
{...mainTargetProps}
className={classNames(mainTargetProps.className, getMainTargetClasses())}
>
{children}
</SingleDropInner>
{dropTypes && dropTypes.length > 1 && (
<EuiFlexGroup
gutterSize="none"
direction="column"
data-test-subj={`${dataTestSubjPrefix}ExtraTargets`}
className={classNames('domDroppable__extraTargets', {
'domDroppable__extraTargets-visible': isInZone || hoveredDropTarget?.id === value.id,
})}
>
{dropTypes.slice(1).map((dropType) => {
const dropChildren = getCustomDropTarget?.(dropType);
return dropChildren ? (
<EuiFlexItem key={dropType} className="domDroppable__extraDropWrapper">
<SingleDropInner {...getProps(dropType, dropChildren)}>
{dropChildren}
</SingleDropInner>
</EuiFlexItem>
) : null;
})}
</EuiFlexGroup>
)}
</div>
);
});
const SingleDropInner = ({
ghost,
children,
...rest
}: {
ghost?: Ghost;
children: ReactElement;
style?: React.CSSProperties;
className?: string;
}) => {
return (
<>
{React.cloneElement(children, rest)}
{ghost
? React.cloneElement(ghost.children, {
className: classNames(
ghost.children.props.className,
ghost.className,
'domDraggable_ghost'
),
style: ghost.style,
})
: null}
</>
);
};
const ReorderableDroppableImpl = memo(function ReorderableDroppableImpl(
props: DropsInnerProps & { reorderableGroup: Array<{ id: string }> }
) {
const { onDrop, value, dndState, dndDispatch, reorderableGroup, className } = props;
const { dragging, dataTestSubjPrefix, hoveredDropTarget } = dndState;
const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id);
const [{ isReorderOn, reorderedItems, draggingHeight, direction }, reorderDispatch] =
useContext(ReorderContext);
const heightRef = useRef<HTMLDivElement>(null);
const isReordered =
isReorderOn && reorderedItems.some((el) => el.id === value.id) && reorderedItems.length;
useEffect(() => {
if (isReordered && heightRef.current?.clientHeight) {
reorderDispatch({
type: 'registerReorderedItemHeight',
payload: { id: value.id, height: heightRef.current.clientHeight },
});
}
}, [isReordered, reorderDispatch, value.id]);
const onReorderableDragOver = (e: DroppableEvent) => {
e.preventDefault();
// An optimization to prevent a bunch of React churn.
if (hoveredDropTarget?.id !== value?.id && onDrop) {
const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id);
if (!dragging || draggingIndex === -1) {
return;
}
const droppingIndex = currentIndex;
if (draggingIndex === droppingIndex) {
reorderDispatch({ type: 'reset' });
}
reorderDispatch({
type: 'setReorderedItems',
payload: { draggingIndex, droppingIndex, items: reorderableGroup },
});
dndDispatch({
type: 'selectDropTarget',
payload: {
dropTarget: { ...value, dropType: 'reorder', onDrop },
dragging,
},
});
}
};
const onReorderableDrop = (e: DroppableEvent) => {
e.preventDefault();
e.stopPropagation();
if (onDrop && dragging) {
onDrop(dragging, 'reorder');
// setTimeout ensures it will run after dragEnd messaging
setTimeout(() => {
dndDispatch({
type: 'dropToTarget',
payload: {
dragging,
dropTarget: { ...value, dropType: 'reorder', onDrop },
},
});
});
}
dndDispatch({ type: 'resetState' });
};
return (
<>
<div
style={
reorderedItems.some((i) => i.id === value.id)
? {
transform: `translateY(${direction}${draggingHeight}px)`,
}
: undefined
}
ref={heightRef}
data-test-subj={`${dataTestSubjPrefix}-translatableDrop`}
className="domDroppable--translatable"
>
<DroppableImpl
{...props}
className={classNames(className, {
['domDroppable__overlayWrapper']: dragging,
})}
/>
</div>
<div
data-test-subj={`${dataTestSubjPrefix}-reorderableDropLayer`}
className={classNames({
['domDroppable--reorderable']: dragging,
})}
onDrop={onReorderableDrop}
onDragOver={onReorderableDragOver}
onDragLeave={() => {
dndDispatch({ type: 'leaveDropTarget' });
reorderDispatch({ type: 'reset' });
}}
/>
</>
);
});

View file

@ -8,5 +8,6 @@
export * from './types';
export * from './providers';
export * from './drag_drop';
export * from './draggable';
export * from './droppable';
export { DropOverlayWrapper, type DropOverlayWrapperProps } from './drop_overlay_wrapper';

View file

@ -21,9 +21,9 @@ import {
import { DEFAULT_DATA_TEST_SUBJ } from '../constants';
import { announce } from './announcements';
const initialState = {
const defaultState = {
dragging: undefined,
activeDropTarget: undefined,
hoveredDropTarget: undefined,
keyboardMode: false,
dropTargetsByOrder: {},
dataTestSubjPrefix: DEFAULT_DATA_TEST_SUBJ,
@ -33,7 +33,7 @@ const initialState = {
*
* const [ state, dispatch ] = useDragDropContext();
*/
const DragContext = React.createContext<DragContextValue>([initialState, () => {}]);
const DragContext = React.createContext<DragContextValue>([defaultState, () => {}]);
export function useDragDropContext() {
const context = React.useContext(DragContext);
@ -127,7 +127,7 @@ const dragDropReducer = (state: DragContextState, action: DragDropAction) => {
dropTargetsByOrder: undefined,
dragging: undefined,
keyboardMode: false,
activeDropTarget: undefined,
hoveredDropTarget: undefined,
};
case 'registerDropTargets':
return {
@ -143,17 +143,17 @@ const dragDropReducer = (state: DragContextState, action: DragDropAction) => {
dropTargetsByOrder: undefined,
dragging: undefined,
keyboardMode: false,
activeDropTarget: undefined,
hoveredDropTarget: undefined,
};
case 'leaveDropTarget':
return {
...state,
activeDropTarget: undefined,
hoveredDropTarget: undefined,
};
case 'selectDropTarget':
return {
...state,
activeDropTarget: action.payload.dropTarget,
hoveredDropTarget: action.payload.dropTarget,
};
case 'startDragging':
return {
@ -222,23 +222,25 @@ const useA11yMiddleware = () => {
export function RootDragDropProvider({
children,
dataTestSubj = DEFAULT_DATA_TEST_SUBJ,
customMiddleware,
initialState = {},
}: {
children: React.ReactNode;
dataTestSubj?: string;
customMiddleware?: CustomMiddleware;
initialState?: Partial<DragContextState>;
}) {
const { a11yMessage, a11yMiddleware } = useA11yMiddleware();
const middlewareFns = React.useMemo(() => {
return customMiddleware ? [customMiddleware, a11yMiddleware] : [a11yMiddleware];
}, [customMiddleware, a11yMiddleware]);
const dataTestSubj = initialState.dataTestSubjPrefix || DEFAULT_DATA_TEST_SUBJ;
const [state, dispatch] = useReducerWithMiddleware(
dragDropReducer,
{
...defaultState,
...initialState,
dataTestSubjPrefix: dataTestSubj,
},
middlewareFns
);
@ -269,7 +271,7 @@ export function RootDragDropProvider({
export function nextValidDropTarget(
dropTargetsByOrder: RegisteredDropTargets,
activeDropTarget: DropIdentifier | undefined,
hoveredDropTarget: DropIdentifier | undefined,
draggingOrder: [string],
filterElements: (el: DragDropIdentifier) => boolean = () => true,
reverse = false
@ -278,18 +280,13 @@ export function nextValidDropTarget(
return;
}
const filteredTargets: Array<[string, DropIdentifier | undefined]> = Object.entries(
dropTargetsByOrder
).filter(([, dropTarget]) => {
return dropTarget && filterElements(dropTarget);
const filteredTargets = Object.entries(dropTargetsByOrder).filter(([order, dropTarget]) => {
return dropTarget && order !== draggingOrder[0] && filterElements(dropTarget);
});
// filter out secondary targets
const uniqueIdTargets = filteredTargets.reduce(
(
acc: Array<[string, DropIdentifier | undefined]>,
current: [string, DropIdentifier | undefined]
) => {
// filter out secondary targets and targets with the same id as the dragging element
const uniqueIdTargets = filteredTargets.reduce<Array<[string, DropIdentifier]>>(
(acc, current) => {
const [, currentDropTarget] = current;
if (!currentDropTarget) {
return acc;
@ -311,7 +308,7 @@ export function nextValidDropTarget(
});
let currentActiveDropIndex = nextDropTargets.findIndex(
([_, dropTarget]) => dropTarget?.id === activeDropTarget?.id
([, dropTarget]) => typeof dropTarget === 'object' && dropTarget?.id === hoveredDropTarget?.id
);
if (currentActiveDropIndex === -1) {

View file

@ -65,6 +65,10 @@ interface ResetAction {
type: 'reset';
}
interface DragEndAction {
type: 'dragEnd';
}
interface RegisterDraggingItemHeightAction {
type: 'registerDraggingItemHeight';
payload: number;
@ -90,6 +94,7 @@ interface SetReorderedItemsAction {
}
type ReorderAction =
| DragEndAction
| ResetAction
| RegisterDraggingItemHeightAction
| RegisterReorderedItemHeightAction
@ -98,6 +103,8 @@ type ReorderAction =
const reorderReducer = (state: ReorderState, action: ReorderAction) => {
switch (action.type) {
case 'dragEnd':
return { ...state, reorderedItems: [], isReorderOn: false };
case 'reset':
return { ...state, reorderedItems: [] };
case 'registerDraggingItemHeight':
@ -110,7 +117,11 @@ const reorderReducer = (state: ReorderState, action: ReorderAction) => {
),
};
case 'setIsReorderOn':
return { ...state, isReorderOn: action.payload };
return {
...state,
isReorderOn: action.payload,
reorderedItems: action.payload ? state.reorderedItems : [],
};
case 'setReorderedItems':
const { items, draggingIndex, droppingIndex } = action.payload;
return draggingIndex < droppingIndex
@ -146,7 +157,7 @@ export function ReorderProvider({
return (
<div
data-test-subj={`${dataTestSubj}-reorderableGroup`}
className={classNames(className, {
className={classNames(className, 'domDragDrop-group', {
'domDragDrop-isActiveGroup': state.isReorderOn && React.Children.count(children) > 1,
})}
>

View file

@ -22,6 +22,7 @@ export interface HumanData {
export interface Ghost {
children: React.ReactElement;
className?: string;
style: React.CSSProperties;
}
@ -56,7 +57,7 @@ export type DropIdentifier = DragDropIdentifier & {
*/
export type DropHandler = (dropped: DragDropIdentifier, dropType?: DropType) => void;
export type RegisteredDropTargets = Record<string, DropIdentifier | undefined> | undefined;
export type RegisteredDropTargets = Record<string, DropIdentifier> | undefined;
export interface DragContextState {
/**
@ -70,7 +71,7 @@ export interface DragContextState {
/**
* currently selected drop target
*/
activeDropTarget?: DropIdentifier;
hoveredDropTarget?: DropIdentifier;
/**
* currently registered drop targets
*/

View file

@ -8,16 +8,15 @@ $domDragDropZLevel3: 3;
// from mixins
// sass-lint:disable-block indentation, no-color-keywords
// Static styles for a draggable item
@mixin mixinDomDraggable {
@include euiSlightShadow;
background: $euiColorEmptyShade;
cursor: grab;
@mixin mixinDomDragDrop {
transition: $euiAnimSpeedFast ease-in-out;
transition-property: background-color, border-color, opacity;
z-index: $domDragDropZLevel1;
border-radius: $euiBorderRadius;
}
// Hovering state for drag item and drop area
@mixin mixinDomDragDropHover {
@mixin mixinDomDraggableHover {
&:hover {
transform: translateX($euiSizeXS);
transition: transform $euiAnimSpeedSlow ease-out;
@ -48,7 +47,7 @@ $domDragDropZLevel3: 3;
}
// Style for drop area while hovering with item
@mixin mixinDomDroppableActiveHover($borderWidth: $euiBorderWidthThin) {
@mixin mixinDomDroppableHover($borderWidth: $euiBorderWidthThin) {
background-color: transparentize($euiColorVis0, .75) !important;
&:before {
border-color: $euiColorVis0 !important;

View file

@ -0,0 +1,44 @@
@import './drag_drop_mixins';
// Draggable item
.domDraggable {
cursor: grab;
@include mixinDomDragDrop;
@include mixinDomDraggableHover;
// Include a possible nested button like when using FieldButton
& .kbnFieldButton__button,
& .euiLink {
cursor: grab;
}
&:focus {
@include euiFocusRing;
}
}
.domDraggable--reorderable {
transform: translateY(0);
transition: transform $euiAnimSpeedFast ease-in-out;
position: relative;
z-index: $domDragDropZLevel1;
}
.domDraggable__keyboardHandler {
top: 0;
position: absolute;
width: 100%;
height: 100%;
border-radius: $euiBorderRadius;
&:focus,
&:focus-within {
@include euiFocusRing;
pointer-events: none;
z-index: $domDragDropZLevel2;
}
}
.domDraggable_active--move, .domDraggable_dragover_keyboard--move {
opacity: 0;
}

View file

@ -1,14 +1,14 @@
@import './drag_drop_mixins';
.domDragDrop {
transition: $euiAnimSpeedFast ease-in-out;
transition-property: background-color, border-color, opacity;
z-index: $domDragDropZLevel1;
// Drop area
.domDroppable {
@include mixinDomDragDrop;
&:not(.domDroppable__overlayWrapper) {
@include mixinDomDroppable;
}
}
.domDragDrop_ghost {
@include mixinDomDraggable;
border: $euiBorderWidthThin dashed $euiBorderColor;
.domDraggable_ghost {
position: absolute !important; // sass-lint:disable-line no-important
margin: 0 !important; // sass-lint:disable-line no-important
top: 0;
@ -22,30 +22,9 @@
outline-style: auto; // Chrome
}
// Draggable item
.domDragDrop-isDraggable {
@include mixinDomDraggable;
@include mixinDomDragDropHover;
// Include a possible nested button like when using FieldButton
& .kbnFieldButton__button,
& .euiLink {
cursor: grab;
}
&:focus {
@include euiFocusRing;
}
}
// Drop area
.domDragDrop-isDroppable:not(.domDragDrop__dropOverlayWrapper) {
@include mixinDomDroppable;
}
// Drop area when there's an item being dragged
.domDragDrop-isDropTarget {
&:not(.domDragDrop__dropOverlayWrapper) {
.domDroppable--active {
&:not(.domDroppable__overlayWrapper) {
@include mixinDomDroppable;
@include mixinDomDroppableActive;
}
@ -56,27 +35,27 @@
}
// Drop area while hovering with item
.domDragDrop-isActiveDropTarget:not(.domDragDrop__dropOverlayWrapper) {
.domDroppable--hover:not(.domDroppable__overlayWrapper) {
z-index: $domDragDropZLevel3;
@include mixinDomDroppableActiveHover;
@include mixinDomDroppableHover;
}
// Drop area that is not allowed for current item
.domDragDrop-isNotDroppable {
.domDroppable--notAllowed {
@include mixinDomDroppableNotAllowed;
}
// Drop area will be replacing existing content
.domDragDrop-isReplacing {
.domDroppable--replacing {
text-decoration: line-through;
}
.domDragDrop-notCompatible:not(.domDragDrop__dropOverlayWrapper) {
.domDroppable--incompatible:not(.domDroppable__overlayWrapper) {
background-color: $euiColorHighlight !important;
&:before {
border: $euiBorderWidthThin dashed $euiColorVis5 !important;
}
&.domDragDrop-isActiveDropTarget {
&.domDroppable--hover {
background-color: rgba(251, 208, 17, .25) !important;
&:before {
border-color: $euiColorVis5 !important;
@ -84,61 +63,37 @@
}
}
.domDragDrop__container {
position: relative;
.domDroppable__container {
width: 100%;
height: 100%;
&.domDragDrop__container-active {
position: relative;
&.domDroppable__container-active {
z-index: $domDragDropZLevel3;
}
.domDroppable__container {
position: static;
}
}
$reorderItemMargin: $euiSizeS;
.domDragDrop__reorderableDrop {
.domDroppable--reorderable {
position: absolute;
width: 100%;
top: 0;
height: calc(100% + #{calc($reorderItemMargin / 2)});
}
.domDragDrop-translatableDrop {
.domDroppable--translatable {
transform: translateY(0);
transition: transform $euiAnimSpeedFast ease-in-out;
pointer-events: none;
.domDragDrop-isDropTarget {
@include mixinDomDraggable;
}
.domDragDrop-isActiveDropTarget {
.domDroppable--hover {
z-index: $domDragDropZLevel3;
}
}
.domDragDrop-translatableDrag {
transform: translateY(0);
transition: transform $euiAnimSpeedFast ease-in-out;
position: relative;
z-index: $domDragDropZLevel1;
}
.domDragDrop__keyboardHandler {
top: 0;
position: absolute;
width: 100%;
height: 100%;
border-radius: $euiBorderRadius;
&:focus,
&:focus-within {
@include euiFocusRing;
pointer-events: none;
z-index: $domDragDropZLevel2;
}
}
.domDragDrop__extraDrops {
.domDroppable__extraTargets {
opacity: 0;
visibility: hidden;
position: absolute;
@ -150,58 +105,58 @@ $reorderItemMargin: $euiSizeS;
max-width: $euiFormMaxWidth;
}
.domDragDrop__extraDrops-visible {
.domDroppable__extraTargets-visible {
opacity: 1;
visibility: visible;
}
.domDragDrop__extraDropWrapper {
.domDroppable__extraDropWrapper {
position: relative;
width: 100%;
height: 100%;
background: $euiColorLightestShade;
border-radius: $euiSizeXS;
.domDragDrop__extraDrop,
.domDragDrop__extraDrop:before {
.domDroppable__extraTarget,
.domDroppable__extraTarget:before {
border-radius: 0;
}
&:first-child .domDragDrop__extraDrop,
&:first-child .domDragDrop__extraDrop:before {
&:first-child .domDroppable__extraTarget,
&:first-child .domDroppable__extraTarget:before {
border-top-left-radius: $euiSizeXS;
border-top-right-radius: $euiSizeXS;
}
&:last-child .domDragDrop__extraDrop,
&:last-child .domDragDrop__extraDrop:before {
&:last-child .domDroppable__extraTarget,
&:last-child .domDroppable__extraTarget:before {
border-bottom-left-radius: $euiSizeXS;
border-bottom-right-radius: $euiSizeXS;
}
}
// collapse borders
.domDragDrop__extraDropWrapper + .domDragDrop__extraDropWrapper {
.domDroppable__extraDropWrapper + .domDroppable__extraDropWrapper {
margin-top: -1px;
}
.domDragDrop__extraDrop {
.domDroppable__extraTarget {
position: relative;
height: $euiSizeXS * 8;
min-width: $euiSize * 7;
color: $euiColorSuccessText;
padding: $euiSizeXS;
&.domDragDrop-incompatibleExtraDrop {
&.domDroppable--incompatibleExtraTarget {
color: $euiColorWarningText;
}
}
.domDragDrop__dropOverlayWrapper {
.domDroppable__overlayWrapper {
position: relative;
height: 100%;
}
.domDragDrop__dropOverlay {
.domDroppable_overlay {
position: absolute;
top: 0;
left: 0;
@ -211,40 +166,21 @@ $reorderItemMargin: $euiSizeS;
transition: $euiAnimSpeedFast ease-in-out;
transition-property: background-color, border-color, opacity;
.domDragDrop-isDropTarget & {
.domDroppable--active & {
@include mixinDomDroppable($euiBorderWidthThick);
@include mixinDomDroppableActive($euiBorderWidthThick);
}
.domDragDrop-isActiveDropTarget & {
@include mixinDomDroppableActiveHover($euiBorderWidthThick);
.domDroppable--hover & {
@include mixinDomDroppableHover($euiBorderWidthThick);
}
}
.domDragDrop-isActiveGroup {
background-color: transparentize($euiColorVis0, .75);
.domDragDrop-isKeyboardReorderInProgress {
.domDragDrop--isDragStarted {
.domDraggable_active_keyboard--reorderable {
.domDraggable_dragover_keyboard--copy {
opacity: 1;
}
}
.domDragDrop-isActiveDropTarget,
.domDragDrop-isDropTarget {
background: $euiColorEmptyShade !important;
}
}
.domDragDrop--isDragStarted {
opacity: .5;
}
// Draggable item when it is moving
.domDragDrop-isHidden {
opacity: 0;
.domDragDrop__keyboardHandler {
&:focus,
&:focus-within {
animation: none;
}
}
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { ReactElement } from 'react';
import faker from 'faker';
import { RenderOptions, render } from '@testing-library/react';
import { DragContextState, RootDragDropProvider } from './providers';
export const EXACT = {
exact: true,
};
export const dataTransfer = {
setData: jest.fn(),
getData: jest.fn(),
};
export const generateDragDropValue = (label = faker.lorem.word()) => ({
id: faker.random.uuid(),
humanData: {
label,
groupLabel: faker.lorem.word(),
position: 1,
canSwap: true,
canDuplicate: true,
layerNumber: 0,
},
});
export const renderWithDragDropContext = (
ui: ReactElement,
renderOptions?: RenderOptions,
contextStateOverrides: Partial<DragContextState> = {}
): any => {
const { wrapper, ...options } = renderOptions || {};
const Wrapper: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
return (
<RootDragDropProvider initialState={contextStateOverrides}>{children}</RootDragDropProvider>
);
};
const rtlRender = render(ui, { wrapper: Wrapper, ...options });
return {
...rtlRender,
};
};

View file

@ -42,7 +42,9 @@
.unifiedFieldListItemButton {
width: 100%;
@include euiSlightShadow;
background: $euiColorEmptyShade;
border-radius: $euiBorderRadius;
&.kbnFieldButton {
&:focus-within,
&-isActive {
@ -66,6 +68,11 @@
}
}
.unifiedFieldList__fieldPopover__fieldPopoverPanel .unifiedFieldListItemButton {
box-shadow: none;
background: none;
}
.unifiedFieldListItemButton--missing {
background: lightOrDarkTheme(transparentize($euiColorMediumShade, .9), $euiColorEmptyShade);
color: $euiColorDarkShade;
@ -96,7 +103,7 @@
&:hover,
&[class*='-isActive'],
.domDragDrop__keyboardHandler:focus + & {
.domDraggable__keyboardHandler:focus + & {
.unifiedFieldListItemButton__fieldIcon {
opacity: 0;
}

View file

@ -65,7 +65,9 @@ export const FieldList: React.FC<FieldListProps> = ({
/>
)}
{!!prepend && <EuiFlexItem grow={false}>{prepend}</EuiFlexItem>}
<EuiFlexItem grow={true}>{children}</EuiFlexItem>
<EuiFlexItem className="unifiedFieldListSidebar__accordionContainer" grow={true}>
{children}
</EuiFlexItem>
{!!append && <EuiFlexItem grow={false}>{append}</EuiFlexItem>}
</EuiFlexGroup>
);

View file

@ -1,8 +1,3 @@
.unifiedFieldList__fieldsAccordion__titleTooltip {
margin-right: $euiSizeXS;
}
.unifiedFieldList__fieldsAccordion__fieldItems {
// Quick fix for making sure the shadow and focus rings are visible outside the accordion bounds
padding: $euiSizeXS;
}

View file

@ -145,7 +145,7 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
<EuiSpacer size="s" />
{hasLoaded &&
(!!fieldsCount ? (
<ul className="unifiedFieldList__fieldsAccordion__fieldItems">
<ul>
{paginatedFields &&
paginatedFields.map((field, index) => (
<Fragment key={getFieldKey(field)}>

View file

@ -10,7 +10,7 @@ import React, { memo, useCallback, useMemo, useState } from 'react';
import { EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { UiCounterMetricType } from '@kbn/analytics';
import { DragDrop } from '@kbn/dom-drag-drop';
import { Draggable } from '@kbn/dom-drag-drop';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { SearchMode } from '../../types';
import { FieldItemButton, type FieldItemButtonProps } from '../../components/field_item_button';
@ -333,8 +333,8 @@ function UnifiedFieldListItemComponent({
<FieldPopover
isOpen={infoIsOpen}
button={
<DragDrop
draggable
<Draggable
dragType="copy"
dragClassName="unifiedFieldListItemButton__dragging"
order={order}
value={value}
@ -361,7 +361,7 @@ function UnifiedFieldListItemComponent({
size,
})}
/>
</DragDrop>
</Draggable>
}
closePopover={closePopover}
data-test-subj={stateService.creationOptions.dataTestSubj?.fieldListItemPopoverDataTestSubj}

View file

@ -25,11 +25,29 @@
}
}
// This code is responsible for making sure that:
// * the shadow and focus rings are visible outside the accordion bounds,
// * the dragged element has a padding to the left and right,
.unifiedFieldListSidebar__list {
padding: $euiSizeS $euiSizeS 0;
padding: $euiSizeS $euiSizeXS 0 $euiSizeXS;
> *, .euiAccordion__triggerWrapper, .euiAccordion__children, .unifiedFieldListItemButton {
padding-left: $euiSizeXS;
padding-right: $euiSizeXS;
}
.unifiedFieldListSidebar__accordionContainer {
padding-left: 0;
padding-right: 0;
}
@include euiBreakpoint('xs', 's') {
padding: $euiSizeS 0 0 0;
> *, .euiAccordion__triggerWrapper, .unifiedFieldListSidebar__accordionContainer, .unifiedFieldListItemButton {
padding-left: 0;
padding-right: 0;
}
}
}
@ -54,14 +72,11 @@
.unifiedFieldListSidebar .unifiedFieldListItemButton {
&.kbnFieldButton {
margin-bottom: calc($euiSizeXS / 2);
}
&.domDragDrop-isDraggable {
background: none;
box-shadow: none;
}
&:not(.unifiedFieldListItemButton__dragging) {
padding: 0;
background: none;
}
}
.unifiedFieldListItemButton__dragging {
background: $euiColorEmptyShade;
}

View file

@ -14,6 +14,7 @@ import {
EuiFlexGroup,
EuiFlexItem,
useEuiFontSize,
useEuiShadow,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
@ -42,7 +43,7 @@ export interface DimensionButtonProps {
message?: Message;
}
export function DimensionButton({
function DimensionButtonImpl({
groupLabel,
children,
onClick,
@ -56,6 +57,7 @@ export function DimensionButton({
<div
{...otherProps}
css={css`
${useEuiShadow('xs')}
${useEuiFontSize('s')}
border-radius: ${euiThemeVars.euiBorderRadius};
position: relative;
@ -66,6 +68,7 @@ export function DimensionButton({
gap: ${euiThemeVars.euiSizeS};
min-height: ${euiThemeVars.euiSizeXL};
padding: ${euiThemeVars.euiSizeXS} ${euiThemeVars.euiSizeS};
background: ${euiThemeVars.euiColorEmptyShade};
`}
>
<EuiFlexGroup direction="row" alignItems="center" gutterSize="none" responsive={false}>
@ -119,8 +122,8 @@ export function DimensionButton({
transition-property: color, opacity, background-color, transform;
opacity: 0;
.domDragDrop:hover &,
.domDragDrop:focus-within & {
.domDraggable:hover &,
.domDraggable:focus-within & {
opacity: 1;
}
&:hover,
@ -133,3 +136,5 @@ export function DimensionButton({
</div>
);
}
export const DimensionButton = React.memo(DimensionButtonImpl);

View file

@ -7,7 +7,7 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
import { DragDrop, type DropType, DropOverlayWrapper } from '@kbn/dom-drag-drop';
import { type DropType, DropOverlayWrapper, Droppable } from '@kbn/dom-drag-drop';
import React, { ReactElement, useCallback, useMemo } from 'react';
import { DataView } from '@kbn/data-views-plugin/common';
import { METRIC_TYPE } from '@kbn/analytics';
@ -106,8 +106,7 @@ export const DiscoverMainContent = ({
const showChart = useAppStateSelector((state) => !state.hideChart);
return (
<DragDrop
draggable={false}
<Droppable
dropTypes={isDropAllowed ? DROP_PROPS.types : undefined}
value={DROP_PROPS.value}
order={DROP_PROPS.order}
@ -144,6 +143,6 @@ export const DiscoverMainContent = ({
)}
</EuiFlexGroup>
</DropOverlayWrapper>
</DragDrop>
</Droppable>
);
};

View file

@ -8,7 +8,8 @@
import { css } from '@emotion/react';
import {
DragDrop,
Droppable,
Draggable,
DropTargetSwapDuplicateCombine,
ReorderProvider,
useDragDropContext,
@ -93,10 +94,10 @@ export const AnnotationList = ({
background-color: ${euiThemeVars.euiColorLightestShade};
padding: ${euiThemeVars.euiSizeS};
border-radius: ${euiThemeVars.euiBorderRadius};
overflow: hidden;
overflow: visible;
.domDragDrop-isActiveGroup {
padding: ${euiThemeVars.euiSizeS};
.domDragDrop-group {
padding: ${euiThemeVars.euiSizeXS} ${euiThemeVars.euiSizeS};
margin: -${euiThemeVars.euiSizeS} -${euiThemeVars.euiSizeS} 0 -${euiThemeVars.euiSizeS};
}
`}
@ -106,11 +107,14 @@ export const AnnotationList = ({
<div
key={index}
css={css`
position: relative; // this is to properly contain the absolutely-positioned drop target in DragDrop
margin-bottom: ${euiThemeVars.euiSizeS};
position: relative; // this is to properly contain the absolutely-positioned drop target in Droppable
& + div {
margin-top: ${euiThemeVars.euiSizeS};
}
`}
>
<DragDrop
<Draggable
dragType="move"
order={[index]}
key={annotation.id}
value={{
@ -119,57 +123,72 @@ export const AnnotationList = ({
label: annotation.label,
},
}}
dragType="move"
dropTypes={dragging && dragging.id !== annotation.id ? ['reorder'] : []}
draggable
reorderableGroup={annotations}
onDrop={(source) => {
const sourceAnnotation = source
? annotations.find(({ id }) => id === source.id)
: undefined;
reorderAnnotations(sourceAnnotation, annotation);
}}
>
<DimensionButton
groupLabel={i18n.translate('eventAnnotationListing.groupEditor.addAnnotation', {
defaultMessage: 'Annotations',
})}
onClick={() => selectAnnotation(annotation)}
onRemoveClick={() =>
updateAnnotations(annotations.filter(({ id }) => id !== annotation.id))
}
accessorConfig={getAnnotationAccessor(annotation)}
label={annotation.label}
<Droppable
order={[index]}
key={annotation.id}
value={{
id: annotation.id,
humanData: {
label: annotation.label,
},
}}
dropTypes={dragging && dragging.id !== annotation.id ? ['reorder'] : []}
reorderableGroup={annotations}
onDrop={(source) => {
const sourceAnnotation = source
? annotations.find(({ id }) => id === source.id)
: undefined;
reorderAnnotations(sourceAnnotation, annotation);
}}
>
<DimensionTrigger label={annotation.label} />
</DimensionButton>
</DragDrop>
<DimensionButton
groupLabel={i18n.translate('eventAnnotationListing.groupEditor.addAnnotation', {
defaultMessage: 'Annotations',
})}
onClick={() => selectAnnotation(annotation)}
onRemoveClick={() =>
updateAnnotations(annotations.filter(({ id }) => id !== annotation.id))
}
accessorConfig={getAnnotationAccessor(annotation)}
label={annotation.label}
>
<DimensionTrigger label={annotation.label} />
</DimensionButton>
</Droppable>
</Draggable>
</div>
))}
</ReorderProvider>
<DragDrop
order={[annotations.length]}
getCustomDropTarget={DropTargetSwapDuplicateCombine.getCustomDropTarget}
getAdditionalClassesOnDroppable={
DropTargetSwapDuplicateCombine.getAdditionalClassesOnDroppable
}
dropTypes={dragging ? ['duplicate_compatible'] : []}
value={{
id: 'addAnnotation',
humanData: {
label: addAnnotationText,
},
}}
onDrop={({ id: sourceId }) => addNewAnnotation(sourceId)}
<div
css={css`
margin-top: ${euiThemeVars.euiSizeXS};
`}
>
<EmptyDimensionButton
dataTestSubj="addAnnotation"
label={addAnnotationText}
ariaLabel={addAnnotationText}
onClick={() => addNewAnnotation()}
/>
</DragDrop>
<Droppable
order={[annotations.length]}
getCustomDropTarget={DropTargetSwapDuplicateCombine.getCustomDropTarget}
getAdditionalClassesOnDroppable={
DropTargetSwapDuplicateCombine.getAdditionalClassesOnDroppable
}
dropTypes={dragging ? ['duplicate_compatible'] : []}
value={{
id: 'addAnnotation',
humanData: {
label: addAnnotationText,
},
}}
onDrop={({ id: sourceId }) => addNewAnnotation(sourceId)}
>
<EmptyDimensionButton
dataTestSubj="addAnnotation"
label={addAnnotationText}
ariaLabel={addAnnotationText}
onClick={() => addNewAnnotation()}
/>
</Droppable>
</div>
</div>
);
};

View file

@ -807,12 +807,12 @@ export class DiscoverPageObject extends FtrService {
* */
public async dragFieldWithKeyboardToTable(fieldName: string) {
const field = await this.find.byCssSelector(
`[data-test-subj="domDragDrop_draggable-${fieldName}"] [data-test-subj="domDragDrop-keyboardHandler"]`
`[data-test-subj="dscFieldListPanelField-${fieldName}"] [data-test-subj="domDragDrop-keyboardHandler"]`
);
await field.focus();
await this.retry.try(async () => {
await this.browser.pressKeys(this.browser.keys.ENTER);
await this.testSubjects.exists('.domDragDrop-isDropTarget'); // checks if we're in dnd mode and there's any drop target active
await this.testSubjects.exists('.domDroppable--active'); // checks if we're in dnd mode and there's any drop target active
});
await this.browser.pressKeys(this.browser.keys.RIGHT);
await this.browser.pressKeys(this.browser.keys.ENTER);

View file

@ -60,4 +60,4 @@
.mappingsEditor__fieldsListItem__actions {
padding-left: $euiSizeS;
}
}

View file

@ -1,4 +1,4 @@
.lnsFieldItem__fieldPanel {
min-width: 260px;
max-width: 300px;
}
}

View file

@ -23,7 +23,6 @@ import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { loadFieldStats } from '@kbn/unified-field-list/src/services/field_stats';
import { DOCUMENT_FIELD_NAME } from '../../../common/constants';
import { FieldIcon } from '@kbn/field-utils';
import { FieldStats, FieldPopoverFooter } from '@kbn/unified-field-list';
@ -31,7 +30,7 @@ jest.mock('@kbn/unified-field-list/src/services/field_stats', () => ({
loadFieldStats: jest.fn().mockResolvedValue({}),
}));
const clickField = async (wrapper: ReactWrapper, field: string) => {
const clickField = async (wrapper: ReactWrapper, field?: string) => {
await act(async () => {
await wrapper
.find(`[data-test-subj="lnsFieldListPanelField-${field}"] .kbnFieldButton__button`)
@ -91,14 +90,14 @@ describe('Lens Field Item', () => {
fields: [
{
name: 'timestamp',
displayName: 'timestampLabel',
displayName: 'timestamp',
type: 'date',
aggregatable: true,
searchable: true,
},
{
name: 'bytes',
displayName: 'bytesLabel',
displayName: 'bytes',
type: 'number',
aggregatable: true,
searchable: true,
@ -154,7 +153,7 @@ describe('Lens Field Item', () => {
filters: [],
field: {
name: 'bytes',
displayName: 'bytesLabel',
displayName: 'bytes',
type: 'number',
aggregatable: true,
searchable: true,
@ -189,7 +188,7 @@ describe('Lens Field Item', () => {
// Using .toContain over .toEqual because this element includes text from <EuiScreenReaderOnly>
// which can't be seen, but shows in the text content
expect(wrapper.find('[data-test-subj="lnsFieldListPanelField"]').first().text()).toContain(
'bytesLabel'
'bytes'
);
});
@ -260,7 +259,7 @@ describe('Lens Field Item', () => {
const field = {
name: 'test',
displayName: 'testLabel',
displayName: 'test',
type: 'string',
aggregatable: true,
searchable: true,
@ -422,7 +421,7 @@ describe('Lens Field Item', () => {
field: documentField,
});
await clickField(wrapper, DOCUMENT_FIELD_NAME);
await clickField(wrapper, documentField.name);
await wrapper.update();

View file

@ -22,7 +22,7 @@ import {
FieldItemButton,
type GetCustomFieldType,
} from '@kbn/unified-field-list';
import { DragDrop } from '@kbn/dom-drag-drop';
import { Draggable } from '@kbn/dom-drag-drop';
import { generateFilters, getEsQueryConfig } from '@kbn/data-plugin/public';
import { type DatatableColumn } from '@kbn/expressions-plugin/common';
import { DatasourceDataPanelProps } from '../../types';
@ -200,19 +200,18 @@ export function InnerFieldItem(props: FieldItemProps) {
closePopover={closePopover}
panelClassName="lnsFieldItem__fieldPanel"
initialFocus=".lnsFieldItem__fieldPanel"
className="lnsFieldItem__popoverAnchor"
data-test-subj="lnsFieldListPanelField"
panelProps={{
'data-test-subj': 'lnsFieldListPanelFieldContent',
}}
container={document.querySelector<HTMLElement>('.application') || undefined}
button={
<DragDrop
draggable
order={order}
<Draggable
dragType="copy"
value={value}
dataTestSubj={`lnsFieldListPanelField-${field.name}`}
order={order}
onDragStart={closePopover}
dataTestSubj={`lnsFieldListPanelField-${field.name}`}
>
{isTextBasedColumnField(field) ? (
<FieldItemButton<DatatableColumn>
@ -223,7 +222,7 @@ export function InnerFieldItem(props: FieldItemProps) {
) : (
<FieldItemButton field={field} {...commonFieldItemButtonProps} />
)}
</DragDrop>
</Draggable>
}
renderHeader={() => {
return (

View file

@ -7,11 +7,12 @@
import React, { useMemo, useCallback, ReactElement } from 'react';
import {
DragDrop,
DragDropIdentifier,
useDragDropContext,
DropType,
DropTargetSwapDuplicateCombine,
Draggable,
Droppable,
} from '@kbn/dom-drag-drop';
import { isDraggedField } from '../../../../utils';
import {
@ -50,8 +51,8 @@ export function DraggableDimensionButton({
};
order: [2, number, number, number];
onDrop: (source: DragDropIdentifier, dropTarget: DragDropIdentifier, dropType?: DropType) => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragStart?: () => void;
onDragEnd?: () => void;
activeVisualization: Visualization<unknown, unknown>;
group: VisualizationDimensionGroupConfig;
children: ReactElement;
@ -138,24 +139,29 @@ export function DraggableDimensionButton({
className="lnsLayerPanel__dimensionContainer"
data-test-subj={group.dataTestSubj}
>
<DragDrop
draggable
getCustomDropTarget={DropTargetSwapDuplicateCombine.getCustomDropTarget}
getAdditionalClassesOnEnter={DropTargetSwapDuplicateCombine.getAdditionalClassesOnEnter}
getAdditionalClassesOnDroppable={
DropTargetSwapDuplicateCombine.getAdditionalClassesOnDroppable
}
order={order}
<Draggable
dragType={isOperation(dragging) ? 'move' : 'copy'}
dropTypes={dropTypes}
order={order}
reorderableGroup={reorderableGroup.length > 1 ? reorderableGroup : undefined}
value={value}
onDrop={handleOnDrop}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{children}
</DragDrop>
<Droppable
dropTypes={dropTypes}
getCustomDropTarget={DropTargetSwapDuplicateCombine.getCustomDropTarget}
getAdditionalClassesOnEnter={DropTargetSwapDuplicateCombine.getAdditionalClassesOnEnter}
getAdditionalClassesOnDroppable={
DropTargetSwapDuplicateCombine.getAdditionalClassesOnDroppable
}
order={order}
reorderableGroup={reorderableGroup.length > 1 ? reorderableGroup : undefined}
value={value}
onDrop={handleOnDrop}
>
{children}
</Droppable>
</Draggable>
</div>
);
}

View file

@ -9,11 +9,11 @@ import React, { useMemo, useState, useEffect } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
DragDrop,
DragDropIdentifier,
useDragDropContext,
DropType,
DropTargetSwapDuplicateCombine,
Droppable,
} from '@kbn/dom-drag-drop';
import { EmptyDimensionButton as EmptyDimensionButtonInner } from '@kbn/visualization-ui-components';
import { css } from '@emotion/react';
@ -180,7 +180,7 @@ export function EmptyDimensionButton({
return (
<div className="lnsLayerPanel__dimensionContainer" data-test-subj={group.dataTestSubj}>
<DragDrop
<Droppable
getCustomDropTarget={DropTargetSwapDuplicateCombine.getCustomDropTarget}
getAdditionalClassesOnDroppable={
DropTargetSwapDuplicateCombine.getAdditionalClassesOnDroppable
@ -201,7 +201,7 @@ export function EmptyDimensionButton({
<DefaultEmptyButton {...buttonProps} />
)}
</div>
</DragDrop>
</Droppable>
</div>
);
}

View file

@ -78,7 +78,7 @@
}
}
.domDragDrop-isReplacing {
.domDroppable--replacing {
.dimensionTrigger__textLabel {
text-decoration: line-through;
}

View file

@ -8,7 +8,7 @@
import React from 'react';
import { screen, fireEvent, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ChildDragDropProvider, DragDrop } from '@kbn/dom-drag-drop';
import { ChildDragDropProvider, Droppable, Draggable } from '@kbn/dom-drag-drop';
import { FramePublicAPI, Visualization, VisualizationConfigProps } from '../../../types';
import { LayerPanel } from './layer_panel';
import { coreMock } from '@kbn/core/public/mocks';
@ -25,7 +25,7 @@ import {
import { createIndexPatternServiceMock } from '../../../mocks/data_views_service_mock';
import { DimensionButton } from '@kbn/visualization-ui-components';
import { LensAppState } from '../../../state_management';
import { ProviderProps } from '@kbn/dom-drag-drop/src';
import type { ProviderProps } from '@kbn/dom-drag-drop/src';
import { LayerPanelProps } from './types';
jest.mock('../../../id_generator');
@ -676,9 +676,9 @@ describe('LayerPanel', () => {
source: draggingField,
})
);
const dragDropElement = screen.getByTestId('lnsDragDrop');
fireEvent.dragOver(dragDropElement);
fireEvent.drop(dragDropElement);
const droppableElement = screen.getByTestId('lnsDragDrop-domDroppable');
fireEvent.dragOver(droppableElement);
fireEvent.drop(droppableElement);
expect(onDropToDimension).toHaveBeenCalledWith(
expect.objectContaining({
@ -720,17 +720,17 @@ describe('LayerPanel', () => {
);
expect(
instance.find('[data-test-subj="lnsGroupTestId"] DragDrop').first().prop('dropType')
instance.find('[data-test-subj="lnsGroupTestId"] Droppable').first().prop('dropType')
).toEqual(undefined);
const dragDropElement = instance
.find('[data-test-subj="lnsGroupTestId"] DragDrop')
const droppableElement = instance
.find('[data-test-subj="lnsGroupTestId"] Droppable')
.first()
.find(DimensionButton)
.first();
dragDropElement.simulate('dragOver');
dragDropElement.simulate('drop');
droppableElement.simulate('dragOver');
droppableElement.simulate('drop');
expect(onDropToDimension).not.toHaveBeenCalled();
});
@ -774,11 +774,11 @@ describe('LayerPanel', () => {
// Simulate drop on the pre-populated dimension
const dragDropElement = instance
.find('[data-test-subj="lnsGroupTestId2"] DragDrop .domDragDrop')
const droppableElement = instance
.find('[data-test-subj="lnsGroupTestId2"] Droppable .domDroppable')
.at(0);
dragDropElement.simulate('dragOver');
dragDropElement.simulate('drop');
droppableElement.simulate('dragOver');
droppableElement.simulate('drop');
expect(onDropToDimension).toHaveBeenCalledWith(
expect.objectContaining({
@ -791,12 +791,12 @@ describe('LayerPanel', () => {
// Simulate drop on the empty dimension
const updatedDragDropElement = instance
.find('[data-test-subj="lnsGroupTestId2"] DragDrop .domDragDrop')
const updatedDroppableElement = instance
.find('[data-test-subj="lnsGroupTestId2"] Droppable .domDroppable')
.last();
updatedDragDropElement.simulate('dragOver');
updatedDragDropElement.simulate('drop');
updatedDroppableElement.simulate('dragOver');
updatedDroppableElement.simulate('drop');
expect(onDropToDimension).toHaveBeenCalledWith(
expect.objectContaining({
@ -828,7 +828,7 @@ describe('LayerPanel', () => {
{ attachTo: holder }
);
act(() => {
instance.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, 'reorder');
instance.find(Droppable).at(1).prop('onDrop')!(draggingOperation, 'reorder');
});
expect(onDropToDimension).toHaveBeenCalledWith(
expect.objectContaining({
@ -837,7 +837,7 @@ describe('LayerPanel', () => {
})
);
const secondButton = instance
.find(DragDrop)
.find(Draggable)
.at(1)
.find('[data-test-subj="lnsDragDrop-keyboardHandler"]')
.at(1)
@ -864,7 +864,7 @@ describe('LayerPanel', () => {
</ChildDragDropProvider>
);
act(() => {
instance.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_compatible');
instance.find(Droppable).at(2).prop('onDrop')!(draggingOperation, 'duplicate_compatible');
});
expect(onDropToDimension).toHaveBeenCalledWith(
expect.objectContaining({
@ -901,7 +901,7 @@ describe('LayerPanel', () => {
</ChildDragDropProvider>
);
act(() => {
instance.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible');
instance.find(Droppable).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible');
});
expect(onDropToDimension).toHaveBeenCalledWith(
expect.objectContaining({
@ -946,7 +946,7 @@ describe('LayerPanel', () => {
</ChildDragDropProvider>
);
act(() => {
instance.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible');
instance.find(Droppable).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible');
});
expect(onDropToDimension).toHaveBeenCalledWith(
@ -997,7 +997,7 @@ describe('LayerPanel', () => {
</ChildDragDropProvider>
);
act(() => {
instance.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible');
instance.find(Droppable).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible');
});
expect(onDropToDimension).toHaveBeenCalledWith(
@ -1033,7 +1033,7 @@ describe('LayerPanel', () => {
</ChildDragDropProvider>
);
act(() => {
instance.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'duplicate_compatible');
instance.find(Droppable).at(3).prop('onDrop')!(draggingOperation, 'duplicate_compatible');
});
});
});

View file

@ -49,8 +49,6 @@ export function LayerPanel(props: LayerPanelProps) {
const [isPanelSettingsOpen, setPanelSettingsOpen] = useState(false);
const [hideTooltip, setHideTooltip] = useState<boolean>(false);
const {
framePublicAPI,
layerId,
@ -552,8 +550,6 @@ export function LayerPanel(props: LayerPanelProps) {
state={layerDatasourceState}
layerDatasource={layerDatasource}
datasourceLayers={framePublicAPI.datasourceLayers}
onDragStart={() => setHideTooltip(true)}
onDragEnd={() => setHideTooltip(false)}
onDrop={onDrop}
indexPatterns={dataViews.indexPatterns}
>
@ -591,7 +587,6 @@ export function LayerPanel(props: LayerPanelProps) {
{activeVisualization?.DimensionTriggerComponent?.({
columnId,
label: columnLabelMap?.[columnId] ?? '',
hideTooltip,
})}
</>
)}

View file

@ -29,7 +29,7 @@ import {
renderWithReduxStore,
} from '../../mocks';
import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks';
import { DragDrop, useDragDropContext } from '@kbn/dom-drag-drop';
import { Droppable, useDragDropContext } from '@kbn/dom-drag-drop';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
@ -626,7 +626,7 @@ describe('editor_frame', () => {
instance.update();
act(() => {
instance.find('[data-test-subj="mockVisA"]').find(DragDrop).prop('onDrop')!(
instance.find('[data-test-subj="mockVisA"]').find(Droppable).prop('onDrop')!(
{
indexPatternId: '1',
field: {},
@ -711,7 +711,7 @@ describe('editor_frame', () => {
instance.update();
act(() => {
instance.find(DragDrop).filter('[dataTestSubj="lnsWorkspace"]').prop('onDrop')!(
instance.find(Droppable).filter('[dataTestSubj="lnsWorkspace"]').prop('onDrop')!(
{
indexPatternId: '1',
field: {},

View file

@ -115,7 +115,10 @@ export function EditorFrame(props: EditorFrameProps) {
}, []);
return (
<RootDragDropProvider dataTestSubj="lnsDragDrop" customMiddleware={telemetryMiddleware}>
<RootDragDropProvider
initialState={{ dataTestSubjPrefix: 'lnsDragDrop' }}
customMiddleware={telemetryMiddleware}
>
<FrameLayout
bannerMessages={
bannerMessages.length ? (

View file

@ -11,7 +11,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { UiActionsStart, VISUALIZE_GEO_FIELD_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { GlobeIllustration } from '@kbn/chart-icons';
import { DragDrop } from '@kbn/dom-drag-drop';
import { Droppable } from '@kbn/dom-drag-drop';
import { IndexPattern } from '../../../types';
import { getVisualizeGeoFieldMessage } from '../../../utils';
import { APP_ID } from '../../../../common/constants';
@ -52,10 +52,9 @@ export function GeoFieldWorkspacePanel(props: Props) {
<strong>{getVisualizeGeoFieldMessage(props.fieldType)}</strong>
</h2>
<GlobeIllustration aria-hidden={true} className="lnsWorkspacePanel__promptIllustration" />
<DragDrop
<Droppable
className="lnsVisualizeGeoFieldWorkspacePanel__dragDrop"
dataTestSubj="lnsGeoFieldWorkspace"
draggable={false}
dropTypes={['field_add']}
order={dragDropOrder}
value={dragDropIdentifier}
@ -69,7 +68,7 @@ export function GeoFieldWorkspacePanel(props: Props) {
/>
</strong>
</p>
</DragDrop>
</Droppable>
</div>
</EuiText>
</div>

View file

@ -936,14 +936,12 @@ describe('workspace_panel', () => {
datasourceState: {},
}),
});
expect(screen.getByTestId('lnsWorkspace').classList).toContain('domDragDrop-isDropTarget');
expect(screen.getByTestId('lnsWorkspace').classList).toContain('domDroppable--active');
});
it('should refuse to drop if there are no suggestions', () => {
renderWithDndAndRedux();
expect(screen.getByTestId('lnsWorkspace').classList).not.toContain(
'domDragDrop-isDropTarget'
);
expect(screen.getByTestId('lnsWorkspace').classList).not.toContain('domDroppable--active');
});
});
});

View file

@ -25,7 +25,7 @@ import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
import type { Datatable } from '@kbn/expressions-plugin/public';
import { DropIllustration } from '@kbn/chart-icons';
import { DragDrop, useDragDropContext, DragDropIdentifier } from '@kbn/dom-drag-drop';
import { useDragDropContext, DragDropIdentifier, Droppable } from '@kbn/dom-drag-drop';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import { ChartSizeSpec, isChartSizeEvent } from '@kbn/chart-expressions-common';
import { trackUiCounterEvents } from '../../../lens_ui_telemetry';
@ -638,19 +638,18 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
: renderDragDropPrompt;
return (
<DragDrop
<Droppable
className={classNames('lnsWorkspacePanel__dragDrop', {
'lnsWorkspacePanel__dragDrop--fullscreen': isFullscreen,
})}
dataTestSubj="lnsWorkspace"
draggable={false}
dropTypes={suggestionForDraggedField ? ['field_add'] : undefined}
onDrop={onDrop}
value={dropProps.value}
order={dropProps.order}
>
<div className="lnsWorkspacePanelWrapper__pageContentBody">{renderWorkspaceContents()}</div>
</DragDrop>
</Droppable>
);
};

View file

@ -46,7 +46,7 @@
}
.lnsWorkspacePanel__dragDrop {
&.domDragDrop-isDropTarget {
&.domDroppable--active {
p {
transition: filter $euiAnimSpeedFast ease-in-out;
filter: blur(5px);
@ -59,7 +59,7 @@
}
}
&.domDragDrop-isActiveDropTarget {
&.domDroppable--hover {
.lnsDropIllustration__hand {
animation: lnsWorkspacePanel__illustrationPulseContinuous 1.5s ease-in-out 0s infinite normal forwards;
}

View file

@ -55,7 +55,7 @@ export function createMockedDragDropContext(
dataTestSubjPrefix: 'lnsDragDrop',
dragging: undefined,
keyboardMode: false,
activeDropTarget: undefined,
hoveredDropTarget: undefined,
dropTargetsByOrder: undefined,
...partialState,
},

View file

@ -1241,8 +1241,7 @@ export interface Visualization<T = unknown, P = T, ExtraAppendLayerArg = unknown
DimensionTriggerComponent?: (props: {
columnId: string;
label: string;
hideTooltip?: boolean;
}) => null | ReactElement<{ columnId: string; label: string; hideTooltip?: boolean }>;
}) => null | ReactElement<{ columnId: string; label: string }>;
getAddLayerButtonComponent?: (
props: AddLayerButtonProps
) => null | ReactElement<AddLayerButtonProps>;

View file

@ -116,7 +116,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {
const breakdownLabel = await testSubjects.find(
'lnsDragDrop_draggable-Top 3 values of extension.raw'
'lnsDragDrop_domDraggable_Top 3 values of extension.raw'
);
const lnsWorkspace = await testSubjects.find('lnsWorkspace');

View file

@ -370,7 +370,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await retry.try(async () => {
await browser.pressKeys(browserKey);
await find.existsByCssSelector(
`.domDragDrop__extraDrop > [data-test-subj="domDragDrop-dropTarget-${metaToAction[metaKey]}"].domDragDrop-isActiveDropTarget`
`.domDroppable__extraTarget > [data-test-subj="domDragDrop-dropTarget-${metaToAction[metaKey]}"].domDroppable--hover`
);
});
},
@ -390,12 +390,12 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
metaKey?: 'shift' | 'alt' | 'ctrl'
) {
const field = await find.byCssSelector(
`[data-test-subj="lnsDragDrop_draggable-${fieldName}"] [data-test-subj="lnsDragDrop-keyboardHandler"]`
`[data-test-subj="lnsFieldListPanelField-${fieldName}"] [data-test-subj="lnsDragDrop-keyboardHandler"]`
);
await field.focus();
await retry.try(async () => {
await browser.pressKeys(browser.keys.ENTER);
await testSubjects.exists('.domDragDrop-isDropTarget'); // checks if we're in dnd mode and there's any drop target active
await testSubjects.exists('.domDroppable--active'); // checks if we're in dnd mode and there's any drop target active
});
for (let i = 0; i < steps; i++) {
await browser.pressKeys(reverse ? browser.keys.LEFT : browser.keys.RIGHT);
@ -515,7 +515,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
* @param endIndex - the index of drop starting from 1
* */
async reorderDimensions(dimension: string, startIndex: number, endIndex: number) {
const dragging = `[data-test-subj='${dimension}']:nth-of-type(${startIndex}) .domDragDrop`;
const dragging = `[data-test-subj='${dimension}']:nth-of-type(${startIndex}) .domDraggable`;
const dropping = `[data-test-subj='${dimension}']:nth-of-type(${endIndex}) [data-test-subj='lnsDragDrop-reorderableDropLayer'`;
await find.existsByCssSelector(dragging);
await browser.html5DragAndDrop(dragging, dropping);
@ -1611,7 +1611,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
const from = `lnsFieldListPanelField-${field}`;
await this.dragEnterDrop(
testSubjects.getCssSelector(from),
testSubjects.getCssSelector(`${to} > lnsDragDrop`),
testSubjects.getCssSelector(`${to} > lnsDragDrop-domDroppable`),
testSubjects.getCssSelector(`${to} > domDragDrop-dropTarget-${type}`)
);
await this.waitForVisualization(visDataTestSubj);
@ -1632,7 +1632,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
) {
await this.dragEnterDrop(
testSubjects.getCssSelector(from),
testSubjects.getCssSelector(`${to} > lnsDragDrop`),
testSubjects.getCssSelector(`${to} > lnsDragDrop-domDroppable`),
testSubjects.getCssSelector(`${to} > domDragDrop-dropTarget-${type}`)
);
await this.waitForVisualization(visDataTestSubj);
@ -1808,7 +1808,12 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
`[data-test-subj="lnsIndexPattern${groupCapitalized}Fields"] .unifiedFieldListItemButton--${type}`
);
// map to testSubjId
return Promise.all(allFieldsForType.map((el) => el.getAttribute('data-test-subj')));
return Promise.all(
allFieldsForType.map(async (el) => {
const parent = await el.findByXpath('./..');
return parent.getAttribute('data-test-subj');
})
);
},
async clickShareMenu() {

View file

@ -104,7 +104,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {
const breakdownLabel = await testSubjects.find(
'lnsDragDrop_draggable-Top 3 values of extension.raw'
'lnsDragDrop_domDraggable_Top 3 values of extension.raw'
);
const lnsWorkspace = await testSubjects.find('lnsWorkspace');