kibana/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx
2021-03-02 11:08:18 +01:00

781 lines
22 KiB
TypeScript

/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import './drag_drop.scss';
import React, { useContext, useEffect, memo } from 'react';
import classNames from 'classnames';
import { keys, EuiScreenReaderOnly } from '@elastic/eui';
import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect';
import {
DragDropIdentifier,
DropIdentifier,
DragContext,
DragContextState,
nextValidDropTarget,
ReorderContext,
ReorderState,
DropHandler,
announce,
} from './providers';
import { trackUiEvent } from '../lens_ui_telemetry';
import { DropType } from '../types';
export type DroppableEvent = React.DragEvent<HTMLElement>;
/**
* The base props to the DragDrop component.
*/
interface BaseProps {
/**
* The CSS class(es) for the root element.
*/
className?: string;
/**
* The event handler that fires when an item
* is dropped onto this DragDrop component.
*/
onDrop?: DropHandler;
/**
* The value associated with this item.
*/
value: DragDropIdentifier;
/**
* Optional comparison function to check whether a value is the dragged one
*/
isValueEqual?: (value1: unknown, value2: unknown) => boolean;
/**
* The React element which will be passed the draggable handlers
*/
children: React.ReactElement;
/**
* 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 a drop - when undefined, the currently dragged item
* cannot be dropped onto this component.
*/
dropType?: DropType;
/**
* 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 DragInnerProps extends BaseProps {
setKeyboardMode: DragContextState['setKeyboardMode'];
setDragging: DragContextState['setDragging'];
setActiveDropTarget: DragContextState['setActiveDropTarget'];
setA11yMessage: DragContextState['setA11yMessage'];
activeDraggingProps?: {
keyboardMode: DragContextState['keyboardMode'];
activeDropTarget: DragContextState['activeDropTarget'];
dropTargetsByOrder: DragContextState['dropTargetsByOrder'];
};
onDragStart?: (
target?:
| DroppableEvent['currentTarget']
| React.KeyboardEvent<HTMLButtonElement>['currentTarget']
) => void;
onDragEnd?: () => void;
extraKeyboardHandler?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
ariaDescribedBy?: string;
}
/**
* The props for a non-draggable instance of that component.
*/
interface DropInnerProps extends BaseProps {
dragging: DragContextState['dragging'];
keyboardMode: DragContextState['keyboardMode'];
setKeyboardMode: DragContextState['setKeyboardMode'];
setDragging: DragContextState['setDragging'];
setActiveDropTarget: DragContextState['setActiveDropTarget'];
setA11yMessage: DragContextState['setA11yMessage'];
registerDropTarget: DragContextState['registerDropTarget'];
isActiveDropTarget: boolean;
isNotDroppable: boolean;
}
const lnsLayerPanelDimensionMargin = 8;
export const DragDrop = (props: BaseProps) => {
const {
dragging,
setDragging,
keyboardMode,
registerDropTarget,
dropTargetsByOrder,
setKeyboardMode,
activeDropTarget,
setActiveDropTarget,
setA11yMessage,
} = useContext(DragContext);
const { value, draggable, dropType, reorderableGroup } = props;
const isDragging = !!(draggable && value.id === dragging?.id);
const activeDraggingProps = isDragging
? {
keyboardMode,
activeDropTarget,
dropTargetsByOrder,
}
: undefined;
if (draggable && !dropType) {
const dragProps = {
...props,
activeDraggingProps,
setKeyboardMode,
setDragging,
setActiveDropTarget,
setA11yMessage,
};
if (reorderableGroup && reorderableGroup.length > 1) {
return <ReorderableDrag {...dragProps} reorderableGroup={reorderableGroup} />;
} else {
return <DragInner {...dragProps} />;
}
}
const isActiveDropTarget = Boolean(activeDropTarget?.id === value.id);
const dropProps = {
...props,
keyboardMode,
setKeyboardMode,
dragging,
setDragging,
isActiveDropTarget,
setActiveDropTarget,
registerDropTarget,
setA11yMessage,
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
!!(!dropType && dragging && value.id !== dragging.id),
};
if (
reorderableGroup &&
reorderableGroup.length > 1 &&
reorderableGroup?.some((i) => i.id === dragging?.id)
) {
return <ReorderableDrop {...dropProps} reorderableGroup={reorderableGroup} />;
}
return <DropInner {...dropProps} />;
};
const removeSelectionBeforeDragging = () => {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
};
const DragInner = memo(function DragInner({
dataTestSubj,
className,
value,
children,
setDragging,
setKeyboardMode,
setActiveDropTarget,
order,
activeDraggingProps,
dragType,
onDragStart,
onDragEnd,
extraKeyboardHandler,
ariaDescribedBy,
setA11yMessage,
}: DragInnerProps) {
const keyboardMode = activeDraggingProps?.keyboardMode;
const activeDropTarget = activeDraggingProps?.activeDropTarget;
const dropTargetsByOrder = activeDraggingProps?.dropTargetsByOrder;
const dragStart = (
e: DroppableEvent | React.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;
setTimeout(() => {
setDragging({
...value,
ghost: keyboardModeOn
? {
children,
style: { width: currentTarget.offsetWidth, height: currentTarget.offsetHeight },
}
: undefined,
});
setA11yMessage(announce.lifted(value.humanData));
if (keyboardModeOn) {
setKeyboardMode(true);
}
if (onDragStart) {
onDragStart(currentTarget);
}
});
};
const dragEnd = (e?: DroppableEvent) => {
e?.stopPropagation();
setDragging(undefined);
setActiveDropTarget(undefined);
setKeyboardMode(false);
setA11yMessage(announce.cancelled(value.humanData));
if (onDragEnd) {
onDragEnd();
}
};
const dropToActiveDropTarget = () => {
if (activeDropTarget) {
trackUiEvent('drop_total');
const { dropType, humanData, onDrop: onTargetDrop } = activeDropTarget;
setTimeout(() => setA11yMessage(announce.dropped(value.humanData, humanData, dropType)));
onTargetDrop(value, dropType);
}
};
const setNextTarget = (reversed = false) => {
if (!order) {
return;
}
const nextTarget = nextValidDropTarget(
dropTargetsByOrder,
activeDropTarget,
[order.join(',')],
(el) => el?.dropType !== 'reorder',
reversed
);
setActiveDropTarget(nextTarget);
setA11yMessage(
nextTarget
? announce.selectedTarget(value.humanData, nextTarget?.humanData, nextTarget?.dropType)
: announce.noTarget()
);
};
const shouldShowGhostImageInstead =
dragType === 'move' &&
keyboardMode &&
activeDropTarget &&
activeDropTarget.dropType !== 'reorder';
return (
<div
className={classNames(className, {
'lnsDragDrop-isHidden-noFocus': shouldShowGhostImageInstead,
})}
data-test-subj={`lnsDragDrop_draggable-${value.humanData.label}`}
>
<EuiScreenReaderOnly showOnFocus>
<button
aria-label={value.humanData.label}
aria-describedby={ariaDescribedBy || `lnsDragDrop-keyboardInstructions`}
className="lnsDragDrop__keyboardHandler"
data-test-subj="lnsDragDrop-keyboardHandler"
onBlur={() => {
if (activeDraggingProps) {
dragEnd();
}
}}
onKeyDown={(e: React.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 && (keys.ARROW_LEFT === key || keys.ARROW_RIGHT === key)) {
setNextTarget(!!(keys.ARROW_LEFT === key));
}
}}
/>
</EuiScreenReaderOnly>
{React.cloneElement(children, {
'data-test-subj': dataTestSubj || 'lnsDragDrop',
className: classNames(children.props.className, 'lnsDragDrop', 'lnsDragDrop-isDraggable', {
'lnsDragDrop-isHidden':
(activeDraggingProps && dragType === 'move' && !keyboardMode) ||
shouldShowGhostImageInstead,
}),
draggable: true,
onDragEnd: dragEnd,
onDragStart: dragStart,
onMouseDown: removeSelectionBeforeDragging,
})}
</div>
);
});
const DropInner = memo(function DropInner(props: DropInnerProps) {
const {
dataTestSubj,
className,
onDrop,
value,
children,
draggable,
dragging,
isNotDroppable,
dropType,
order,
getAdditionalClassesOnEnter,
getAdditionalClassesOnDroppable,
isActiveDropTarget,
registerDropTarget,
setActiveDropTarget,
keyboardMode,
setKeyboardMode,
setDragging,
setA11yMessage,
} = props;
useShallowCompareEffect(() => {
if (dropType && onDrop && keyboardMode) {
registerDropTarget(order, { ...value, onDrop, dropType });
return () => {
registerDropTarget(order, undefined);
};
}
}, [order, value, registerDropTarget, dropType, keyboardMode]);
const classesOnEnter = getAdditionalClassesOnEnter?.(dropType);
const classesOnDroppable = getAdditionalClassesOnDroppable?.(dropType);
const classes = classNames(
'lnsDragDrop',
{
'lnsDragDrop-isDraggable': draggable,
'lnsDragDrop-isDroppable': !draggable,
'lnsDragDrop-isDropTarget': dropType && dropType !== 'reorder',
'lnsDragDrop-isActiveDropTarget': dropType && isActiveDropTarget && dropType !== 'reorder',
'lnsDragDrop-isNotDroppable': isNotDroppable,
},
classesOnEnter && { [classesOnEnter]: isActiveDropTarget },
classesOnDroppable && { [classesOnDroppable]: dropType }
);
const dragOver = (e: DroppableEvent) => {
if (!dropType) {
return;
}
e.preventDefault();
// An optimization to prevent a bunch of React churn.
if (!isActiveDropTarget && dragging && onDrop) {
setActiveDropTarget({ ...value, dropType, onDrop });
setA11yMessage(announce.selectedTarget(dragging.humanData, value.humanData, dropType));
}
};
const dragLeave = () => {
setActiveDropTarget(undefined);
};
const drop = (e: DroppableEvent | React.KeyboardEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (onDrop && dropType && dragging) {
trackUiEvent('drop_total');
onDrop(dragging, dropType);
setTimeout(() =>
setA11yMessage(announce.dropped(dragging.humanData, value.humanData, dropType))
);
}
setDragging(undefined);
setActiveDropTarget(undefined);
setKeyboardMode(false);
};
const ghost =
isActiveDropTarget && dropType !== 'reorder' && dragging?.ghost ? dragging.ghost : undefined;
return (
<>
{React.cloneElement(children, {
'data-test-subj': dataTestSubj || 'lnsDragDrop',
className: classNames(children.props.className, classes, className),
onDragOver: dragOver,
onDragLeave: dragLeave,
onDrop: drop,
draggable,
})}
{ghost
? React.cloneElement(ghost.children, {
className: classNames(ghost.children.props.className, 'lnsDragDrop_ghost'),
style: ghost.style,
})
: null}
</>
);
});
const ReorderableDrag = memo(function ReorderableDrag(
props: DragInnerProps & { reorderableGroup: Array<{ id: string }>; dragging?: DragDropIdentifier }
) {
const {
reorderState: { isReorderOn, reorderedItems, direction },
setReorderState,
} = useContext(ReorderContext);
const {
value,
setActiveDropTarget,
activeDraggingProps,
reorderableGroup,
setA11yMessage,
} = props;
const keyboardMode = activeDraggingProps?.keyboardMode;
const activeDropTarget = activeDraggingProps?.activeDropTarget;
const dropTargetsByOrder = activeDraggingProps?.dropTargetsByOrder;
const isDragging = !!activeDraggingProps;
const isFocusInGroup = keyboardMode
? isDragging &&
(!activeDropTarget || reorderableGroup.some((i) => i.id === activeDropTarget?.id))
: isDragging;
useEffect(() => {
setReorderState((s: ReorderState) => ({
...s,
isReorderOn: isFocusInGroup,
}));
}, [setReorderState, isFocusInGroup]);
const onReorderableDragStart = (
currentTarget?:
| DroppableEvent['currentTarget']
| React.KeyboardEvent<HTMLButtonElement>['currentTarget']
) => {
if (currentTarget) {
const height = currentTarget.offsetHeight + lnsLayerPanelDimensionMargin;
setReorderState((s: ReorderState) => ({
...s,
draggingHeight: height,
}));
}
};
const onReorderableDragEnd = () => {
resetReorderState();
};
const resetReorderState = () =>
setReorderState((s: ReorderState) => ({
...s,
reorderedItems: [],
}));
const extraKeyboardHandler = (e: React.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) {
resetReorderState();
setActiveDropTarget(undefined);
} 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) {
setReorderState((s: ReorderState) => ({
...s,
reorderedItems: [],
}));
setA11yMessage(announce.selectedTarget(value.humanData, value.humanData, 'reorder'));
setActiveDropTarget(target);
return;
}
const droppingIndex = reorderableGroup.findIndex((i) => i.id === target.id);
const draggingIndex = reorderableGroup.findIndex((i) => i.id === value?.id);
if (draggingIndex === -1) {
return;
}
setActiveDropTarget(target);
setA11yMessage(announce.selectedTarget(value.humanData, target.humanData, 'reorder'));
setReorderState((s: ReorderState) =>
draggingIndex < droppingIndex
? {
...s,
reorderedItems: reorderableGroup.slice(draggingIndex + 1, droppingIndex + 1),
direction: '-',
}
: {
...s,
reorderedItems: reorderableGroup.slice(droppingIndex, draggingIndex),
direction: '+',
}
);
};
const areItemsReordered = isDragging && keyboardMode && reorderedItems.length;
return (
<div
data-test-subj="lnsDragDrop-reorderableDrag"
className={
isDragging
? 'lnsDragDrop-reorderable lnsDragDrop-translatableDrag'
: 'lnsDragDrop-reorderable'
}
style={
areItemsReordered
? {
transform: `translateY(${direction === '+' ? '-' : '+'}${reorderedItems.reduce(
(acc, cur) => acc + Number(cur.height || 0) + lnsLayerPanelDimensionMargin,
0
)}px)`,
}
: undefined
}
>
<DragInner
{...props}
ariaDescribedBy="lnsDragDrop-keyboardInstructionsWithReorder"
extraKeyboardHandler={extraKeyboardHandler}
onDragStart={onReorderableDragStart}
onDragEnd={onReorderableDragEnd}
/>
</div>
);
});
const ReorderableDrop = memo(function ReorderableDrop(
props: DropInnerProps & { reorderableGroup: Array<{ id: string }> }
) {
const {
onDrop,
value,
dragging,
setDragging,
setKeyboardMode,
isActiveDropTarget,
setActiveDropTarget,
reorderableGroup,
setA11yMessage,
dropType,
} = props;
const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id);
const {
reorderState: { isReorderOn, reorderedItems, draggingHeight, direction },
setReorderState,
} = useContext(ReorderContext);
const heightRef = React.useRef<HTMLDivElement>(null);
const isReordered =
isReorderOn && reorderedItems.some((el) => el.id === value.id) && reorderedItems.length;
useEffect(() => {
if (isReordered && heightRef.current?.clientHeight) {
setReorderState((s) => ({
...s,
reorderedItems: s.reorderedItems.map((el) =>
el.id === value.id
? {
...el,
height: heightRef.current?.clientHeight,
}
: el
),
}));
}
}, [isReordered, setReorderState, value.id]);
const onReorderableDragOver = (e: DroppableEvent) => {
if (!dropType) {
return;
}
e.preventDefault();
// An optimization to prevent a bunch of React churn.
if (!isActiveDropTarget && dropType && onDrop) {
setActiveDropTarget({ ...value, dropType, onDrop });
}
const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id);
if (!dragging || draggingIndex === -1) {
return;
}
const droppingIndex = currentIndex;
if (draggingIndex === droppingIndex) {
setReorderState((s: ReorderState) => ({
...s,
reorderedItems: [],
}));
}
setReorderState((s: ReorderState) =>
draggingIndex < droppingIndex
? {
...s,
reorderedItems: reorderableGroup.slice(draggingIndex + 1, droppingIndex + 1),
direction: '-',
}
: {
...s,
reorderedItems: reorderableGroup.slice(droppingIndex, draggingIndex),
direction: '+',
}
);
};
const onReorderableDrop = (e: DroppableEvent) => {
e.preventDefault();
e.stopPropagation();
setActiveDropTarget(undefined);
setDragging(undefined);
setKeyboardMode(false);
if (onDrop && dropType && dragging) {
trackUiEvent('drop_total');
onDrop(dragging, 'reorder');
// setTimeout ensures it will run after dragEnd messaging
setTimeout(() =>
setA11yMessage(announce.dropped(dragging.humanData, value.humanData, 'reorder'))
);
}
};
return (
<div>
<div
style={
reorderedItems.some((i) => i.id === value.id)
? {
transform: `translateY(${direction}${draggingHeight}px)`,
}
: undefined
}
ref={heightRef}
data-test-subj="lnsDragDrop-translatableDrop"
className="lnsDragDrop-translatableDrop lnsDragDrop-reorderable"
>
<DropInner {...props} />
</div>
<div
data-test-subj="lnsDragDrop-reorderableDropLayer"
className={classNames('lnsDragDrop', {
['lnsDragDrop__reorderableDrop']: dragging && dropType,
})}
onDrop={onReorderableDrop}
onDragOver={onReorderableDragOver}
onDragLeave={() => {
setReorderState((s: ReorderState) => ({
...s,
reorderedItems: [],
}));
}}
/>
</div>
);
});