/* * 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; /** * 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['currentTarget'] ) => void; onDragEnd?: () => void; extraKeyboardHandler?: (e: React.KeyboardEvent) => 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 ; } else { return ; } } 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 ; } return ; }; 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, 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 (
); }); 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) => { 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['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) => { 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 (
acc + Number(cur.height || 0) + lnsLayerPanelDimensionMargin, 0 )}px)`, } : undefined } >
); }); 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(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 (
i.id === value.id) ? { transform: `translateY(${direction}${draggingHeight}px)`, } : undefined } ref={heightRef} data-test-subj="lnsDragDrop-translatableDrop" className="lnsDragDrop-translatableDrop lnsDragDrop-reorderable" >
{ setReorderState((s: ReorderState) => ({ ...s, reorderedItems: [], })); }} />
); });