diff --git a/app/src/demo/dnd/DndCriterion.tsx b/app/src/demo/dnd/DndCriterion.tsx index b2b06e0f21..148a7750f1 100644 --- a/app/src/demo/dnd/DndCriterion.tsx +++ b/app/src/demo/dnd/DndCriterion.tsx @@ -41,6 +41,7 @@ export class DndCriterion extends React.Component { return ( { return ( { return ( { return ( metadata, }); - const [tableState, setTableState] = useState({ sorting: [{ field: 'order' }], visibleCount: 1000 }); + const [tableState, setTableState] = useState({ sorting: [{ field: 'order' }], visibleCount: 1000, folded: { 1: false } }); const dataTableFocusManager = useDataTableFocusManager({}, []); const searchHandler = useCallback( @@ -122,6 +122,7 @@ export function ProjectTableDemo() { getRowOptions: (task) => ({ ...lens.prop('items').key(task.id).toProps(), // pass IEditable to each row to allow editing isSelectable: true, + // checkbox: { isVisible: true }, dnd: { srcData: { ...task, isTask: true }, dstData: { ...task, isTask: true }, diff --git a/app/src/docs/_examples/dnd/Basic.example.tsx b/app/src/docs/_examples/dnd/Basic.example.tsx index cfee0386d2..2ea4dc0add 100644 --- a/app/src/docs/_examples/dnd/Basic.example.tsx +++ b/app/src/docs/_examples/dnd/Basic.example.tsx @@ -61,6 +61,7 @@ export default function DndMaterial() { const renderMaterial = (item: MaterialItem, prevItem: MaterialItem, nextItem: MaterialItem) => ( extends React.Component(prop }); }; + const renderDropLevels = (params: DndDropLevelsRenderParams) => { + return ( + + { params.dropLevelsProps.map((dropLevelProps) => props.renderDropLevel({ ...dropLevelProps, isRowCheckable: props.isCheckable })) } + + ); + }; + const renderRow = (params: Partial, clickHandler?: (props: DataRowProps) => void, overlays?: ReactNode) => { return ( (prop props.isSelected && uuiMod.selected, params.isDraggable && uuiMarkers.draggable, props.isInvalid && uuiMod.invalid, + params.isRowHighlighted && uuiMarkers.dropping, + params.isDndInProgress && uuiDndState.dragInProgress, uuiDataTableRow.uuiTableRow, props.cx, props.isFocused && uuiMod.focus, @@ -80,11 +94,53 @@ const DataTableRowImpl = React.forwardRef(function DataTableRow(prop /> ); }; + + const renderDragGhost = (params: Partial, clickHandler?: (props: DataRowProps) => void, overlays?: ReactNode) => { + return ( + clickHandler(props)) } + rawProps={ { + ...props.rawProps, + ...params.eventHandlers, + role: 'row', + 'aria-expanded': (props.isFolded === undefined || props.isFolded === null) ? undefined : !props.isFolded, + ...(props.isSelectable && { 'aria-selected': props.isSelected }), + style: { width: props.columns[0].width, minWidth: props.columns[0].minWidth ?? props.columns[0].width }, + } } + cx={ [ + params.classNames, + props.isSelected && uuiMod.selected, + params.isDraggable && uuiMarkers.draggable, + props.isInvalid && uuiMod.invalid, + params.isRowHighlighted && uuiMarkers.dropping, + uuiDataTableRow.uuiTableRow, + uuiDndState.dragGhost, + props.cx, + props.isFocused && uuiMod.focus, + ] } + overlays={ overlays } + link={ props.link } + /> + ); + }; const clickHandler = props.onClick || props.onSelect || props.onFold || props.onCheck; if (props.dnd && (props.dnd.srcData || props.dnd.canAcceptDrop)) { - return renderRow(params, clickHandler, props.renderDropMarkers?.(params)) } />; + return ( + id) } + render={ (params, overlays) => renderRow(params, clickHandler, overlays) } + renderDragGhost={ (params, overlays) => renderDragGhost(params, clickHandler, overlays) } + renderDropLevels={ props.renderDropLevel ? renderDropLevels : null } + /> + ); } else { return renderRow({}, clickHandler); } diff --git a/uui-core/src/constants/selectors.ts b/uui-core/src/constants/selectors.ts index 6c87477b80..b2f7b61a9d 100644 --- a/uui-core/src/constants/selectors.ts +++ b/uui-core/src/constants/selectors.ts @@ -62,12 +62,15 @@ export const uuiMarkers = { scrolledRight: '-scrolled-right', scrolledTop: '-scrolled-top', scrolledBottom: '-scrolled-bottom', + dropping: 'uui-dropping-marker', } as const; export const uuiDndState = { draggedOut: 'uui-dragged-out', dropAccepted: 'uui-drop-accepted', dragGhost: 'uui-drag-ghost', + dragHandle: 'uui-drag-handle', + dragInProgress: 'uui-drag-in-progress', } as const; export const uuiDataTableHeaderCell = { diff --git a/uui-core/src/services/dnd/DndActor.tsx b/uui-core/src/services/dnd/DndActor.tsx index 271866b8c1..21852eda88 100644 --- a/uui-core/src/services/dnd/DndActor.tsx +++ b/uui-core/src/services/dnd/DndActor.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { IDndActor, DropPosition, AcceptDropParams, DndActorRenderParams, DropPositionOptions, + DndDropLevelsRenderParams, + DndDropLevelRenderProps, } from '../../types/dnd'; import { UuiContexts } from '../../types/contexts'; @@ -15,9 +17,16 @@ import { uuiDndState, uuiMarkers } from '../../constants'; import { UuiContext } from '../UuiContext'; import { DndContextState } from './DndContext'; -export interface DndActorProps extends IDndActor { +export interface DndActorProps extends IDndActor { /** Render callback for DragActor content */ - render(props: DndActorRenderParams): React.ReactNode; + render(props: DndActorRenderParams, overlays?: React.ReactNode): React.ReactNode; + renderDropLevels?(props: DndDropLevelsRenderParams): React.ReactNode; + renderDragGhost?(props: DndActorRenderParams, overlays?: React.ReactNode): React.ReactNode; + + isMultilevel?: boolean; + + id: TId; + path?: TId[]; } const DND_START_THRESHOLD = 5; @@ -29,6 +38,8 @@ interface DndActorState { isDragging: boolean; isMouseOver: boolean; position?: DropPosition; + draggingOverLevel: number | null; + dndContextState: DndContextState; } @@ -39,8 +50,10 @@ const initialState: DndActorState = { isDragging: false, isMouseOver: false, position: null, + draggingOverLevel: null, dndContextState: { isDragging: false, + draggingOverInfo: null, }, }; @@ -52,7 +65,7 @@ const initialState: DndActorState = { export const DndActor = TREE_SHAKEABLE_INIT(); function TREE_SHAKEABLE_INIT() { - return class DndActorComponent extends React.Component, DndActorState> { + return class DndActorComponent extends React.Component, DndActorState> { state = initialState; static contextType = UuiContext; public context: UuiContexts; @@ -60,6 +73,12 @@ function TREE_SHAKEABLE_INIT() { componentDidMount() { this.context?.uuiDnD?.subscribe?.(this.contextUpdateHandler); + this.context?.uuiDnD.setDndRowData({ + id: this.props.id, + path: this.props.path ?? [], + dstData: this.props.dstData, + srcData: this.props.srcData, + }); window.addEventListener('pointerup', this.windowPointerUpHandler); window.addEventListener('pointermove', this.windowPointerMoveHandler); } @@ -97,7 +116,7 @@ function TREE_SHAKEABLE_INIT() { if (dist > DND_START_THRESHOLD) { this.context.uuiDnD.startDrag(this.dndRef.current, this.props.srcData, () => - this.props.render({ + (this.props.renderDragGhost ?? this.props.render)({ isDragGhost: true, isDraggedOver: false, isDraggable: false, @@ -201,6 +220,87 @@ function TREE_SHAKEABLE_INIT() { } } + private getMultilevelDropParams = (id: TId, e: React.MouseEvent): AcceptDropParams => { + const { + left, top, width, height, + } = e.currentTarget.getBoundingClientRect(); + const dndRowData = this.context?.uuiDnD?.getDndRowData(id); + return { + srcData: this.context.uuiDnD.dragData, + dstData: dndRowData?.dstData, + offsetLeft: e.clientX - left, + offsetTop: e.clientY - top, + targetWidth: width, + targetHeight: height, + }; + }; + + private pointerEnterDropLevel = (id: TId, position: DropPosition, level: number) => (e: React.PointerEvent) => { + if (this.context.uuiDnD.isDragging) { + releasePointerCaptureOnEventTarget(e); // allows you to trigger pointer events on other nodes + const dropProps = this.getMultilevelDropParams(id, e); + const positionOptions = this.props.canAcceptDrop(dropProps); + const actualPosition = positionOptions?.[position] ? position : null; + this.setState((s) => ({ + ...s, + isMouseOver: true, + position: actualPosition, + draggingOverLevel: level, + })); + this.context?.uuiDnD.setDraggingOverInfo(positionOptions?.[position] ? { id, position } : null); + } + }; + + private onMultilevelDropPointerUp = (id: TId) => (e: React.PointerEvent) => { + if (this.context.uuiDnD.isDragging) { + if (isEventTargetInsideDraggable(e, e.currentTarget)) { + return; + } + e.preventDefault(); + if (!!this.state.position && this.props.onDrop) { + this.props.onDrop({ + ...this.getMultilevelDropParams(id, e), + position: this.state.position, + }); + } + this.context.uuiDnD.endDrag(); + this.setState(() => initialState); + } + }; + + getDropLevelsProps(): DndDropLevelRenderProps[] { + const lastLevel = this.props.path.length + 2; + const lastPosition = 'inside'; + const isDraggedOver = this.context.uuiDnD?.isDragging && this.state.isMouseOver; + const draggingOverLevel = this.state.draggingOverLevel; + return [ + ...[...this.props.path, this.props.id].map((id, index) => { + const position = 'bottom'; + const level = index + 1; + return { + id, + level, + lastLevel, + key: `${id}-${position}`, + isDraggedOver, + draggingOverLevel, + onPointerUp: this.onMultilevelDropPointerUp(id), + onPointerEnter: this.pointerEnterDropLevel(id, position, level), + }; + }), + { + id: this.props.id, + level: lastLevel, + lastLevel, + key: `${this.props.id}-${lastPosition}`, + isDraggedOver, + draggingOverLevel, + onPointerUp: this.onMultilevelDropPointerUp(this.props.id), + onPointerEnter: this.pointerEnterDropLevel(this.props.id, lastPosition, lastLevel), + }, + ]; + } + render() { const params: DndActorRenderParams = { isDraggable: !!this.props.srcData, @@ -250,7 +350,7 @@ function TREE_SHAKEABLE_INIT() { if (this.props.canAcceptDrop) { const pointerLeaveHandler = () => { if (this.context.uuiDnD.isDragging) { - this.setState((s) => ({ ...s, isMouseOver: false, position: null })); + this.setState((s) => ({ ...s, isMouseOver: false, position: null, draggingOverLevel: null })); } }; @@ -271,12 +371,12 @@ function TREE_SHAKEABLE_INIT() { params.eventHandlers.onTouchStart = (e) => e.preventDefault(); // prevent defaults on ios - params.eventHandlers.onPointerEnter = pointerMoveHandler; - params.eventHandlers.onPointerMove = pointerMoveHandler; + params.eventHandlers.onPointerEnter = !this.props.isMultilevel ? pointerMoveHandler : null; + params.eventHandlers.onPointerMove = !this.props.isMultilevel ? pointerMoveHandler : null; params.eventHandlers.onPointerLeave = pointerLeaveHandler; } - params.eventHandlers.onPointerUp = (e) => { + params.eventHandlers.onPointerUp = !this.props.isMultilevel ? (e) => { if (this.context.uuiDnD.isDragging) { if (isEventTargetInsideDraggable(e, e.currentTarget)) { return; @@ -299,9 +399,51 @@ function TREE_SHAKEABLE_INIT() { // this.setState(s => ({ ...s, pendingMouseDownTarget: null })); // } } + } : null; + + const shouldHighlightRow = (id: TId) => { + if (this.state.dndContextState.draggingOverInfo == null) { + return false; + } + + const rowData = this.context?.uuiDnD?.getDndRowData(id); + const draggingOverItemData = this.context?.uuiDnD?.getDndRowData(this.state.dndContextState.draggingOverInfo.id); + + const { position } = this.state.dndContextState.draggingOverInfo; + if (position === 'bottom') { + const draggingOverItemParent = draggingOverItemData.path[draggingOverItemData.path.length - 1]; + const rowParent = rowData.path[rowData.path.length - 1]; + if ( + draggingOverItemData.id === rowData.id + || draggingOverItemParent === rowParent + || draggingOverItemParent === rowData.id) { + return true; + } + return false; + } + + if (position === 'inside') { + if (rowData.id === draggingOverItemData.id + || rowData.path[rowData.path.length - 1] === draggingOverItemData.id + ) { + return true; + } + return false; + } + + return false; }; - return this.props.render(params); + params.isRowHighlighted = shouldHighlightRow(this.props.id); + + return this.props.render( + params, + this.props.isMultilevel && this.state.dndContextState.isDragging + ? this.props.renderDropLevels?.({ + dropLevelsProps: this.getDropLevelsProps(), + }) + : null, + ); } }; } diff --git a/uui-core/src/services/dnd/DndContext.tsx b/uui-core/src/services/dnd/DndContext.tsx index ea0eeaab06..6bddd5b63a 100644 --- a/uui-core/src/services/dnd/DndContext.tsx +++ b/uui-core/src/services/dnd/DndContext.tsx @@ -2,13 +2,16 @@ import { isClientSide } from '../../helpers/ssr'; import { getOffset } from '../../helpers/getOffset'; import { getScrollParentOfEventTarget } from '../../helpers/events'; import * as React from 'react'; -import { IDndContext } from '../../types/contexts'; +import { IDndContext, IDraggingOverInfo } from '../../types/contexts'; import { BaseContext } from '../BaseContext'; +import { MouseCoordsService, TMouseCoords } from './MouseCoordsService'; +import { DndRowData, DndRowsDataService } from './DndRowsDataService'; const maxScrollSpeed = 2000; // px/second -export interface DndContextState { +export interface DndContextState { isDragging: boolean; + draggingOverInfo?: IDraggingOverInfo | null; ghostOffsetX?: number; ghostOffsetY?: number; ghostWidth?: number; @@ -16,9 +19,10 @@ export interface DndContextState { renderGhost?(): React.ReactNode; } -export class DndContext extends BaseContext implements IDndContext { +export class DndContext extends BaseContext> implements IDndContext { public isDragging = false; - public dragData: any; + public draggingOverInfo: IDraggingOverInfo | null = null; + public dragData: TSrcData; private scrollZoneSize = 85; private ghostOffsetX: number = 0; private ghostOffsetY: number = 0; @@ -26,6 +30,7 @@ export class DndContext extends BaseContext implements IDndCont private renderGhostCallback: () => React.ReactNode = null; private lastScrollTime = new Date().getTime(); private mouseCoordsService = new MouseCoordsService(); + private rowsDataService = new DndRowsDataService(); init() { super.init(); @@ -44,13 +49,26 @@ export class DndContext extends BaseContext implements IDndCont window.removeEventListener('pointerup', this.windowPointerUpHandler); this.mouseCoordsService.destroy(); } + this.rowsDataService.destroy(); } public getMouseCoords = (): TMouseCoords => { return this.mouseCoordsService.getCoords(); }; + + public setDndRowData(data: DndRowData) { + this.rowsDataService.setDndRowData(data); + } + + public getDndRowData(id: TId) { + return this.rowsDataService.getDndRowData(id); + } - public startDrag(node: HTMLElement, data: {}, renderGhost: () => React.ReactNode) { + public setDraggingOverInfo(info: IDraggingOverInfo) { + this.update({ isDragging: this.isDragging, draggingOverInfo: info }); + } + + public startDrag(node: HTMLElement, data: TSrcData, renderGhost: () => React.ReactNode) { const offset = getOffset(node); const mouseCoords = this.mouseCoordsService.getCoords(); @@ -67,6 +85,7 @@ export class DndContext extends BaseContext implements IDndCont this.update({ isDragging: true, + draggingOverInfo: null, ghostOffsetX: this.ghostOffsetX, ghostOffsetY: this.ghostOffsetY, ghostWidth: this.ghostWidth, @@ -78,6 +97,7 @@ export class DndContext extends BaseContext implements IDndCont const ev = document.createEvent('Events'); ev.initEvent('dragstart', true, false); document.body.dispatchEvent(ev); + document.body.style.cursor = 'grabbing'; } public endDrag() { @@ -86,12 +106,13 @@ export class DndContext extends BaseContext implements IDndCont } new Promise((res) => { - this.update({ isDragging: false }); + this.update({ isDragging: false, draggingOverInfo: null }); res(); }).then(() => { this.renderGhostCallback = null; this.dragData = null; this.isDragging = false; + document.body.style.cursor = 'default'; }); } @@ -172,75 +193,3 @@ export class DndContext extends BaseContext implements IDndCont } } } - -export type TMouseCoords = { - mousePageX: number, - mousePageY: number, - mouseDx: number, - mouseDy: number, - mouseDxSmooth: number, - mouseDySmooth: number, - mouseDownPageX: number, - mouseDownPageY: number, - buttons: number, -}; - -class MouseCoordsService { - private _prevMouseCoords: TMouseCoords; - - init = () => { - this._prevMouseCoords = { - mousePageX: 0, - mousePageY: 0, - mouseDx: 0, - mouseDy: 0, - mouseDxSmooth: 0, - mouseDySmooth: 0, - mouseDownPageX: 0, - mouseDownPageY: 0, - buttons: 0, - }; - if (isClientSide) { - document.addEventListener('pointermove', this.handleMouseCoordsChange); - } - }; - - public destroy() { - if (isClientSide) { - document.removeEventListener('pointermove', this.handleMouseCoordsChange); - } - } - - private handleMouseCoordsChange = (e: PointerEvent) => { - this._prevMouseCoords = getMouseCoordsFromPointerEvent(e, this._prevMouseCoords); - }; - - public getCoords = () => { - return this._prevMouseCoords; - }; -} - -function getMouseCoordsFromPointerEvent(e: PointerEvent, prevCoords: TMouseCoords): TMouseCoords { - const mouseDx = e.pageX - prevCoords.mousePageX; - const mouseDy = e.pageY - prevCoords.mousePageY; - const mouseDxSmooth = prevCoords.mouseDxSmooth * 0.8 + mouseDx * 0.2; - const mouseDySmooth = prevCoords.mouseDySmooth * 0.8 + mouseDy * 0.2; - const mousePageX = e.pageX; - const mousePageY = e.pageY; - const result: TMouseCoords = { - mouseDx, - mouseDy, - mouseDxSmooth, - mouseDySmooth, - mousePageX, - mousePageY, - buttons: e.buttons, - mouseDownPageX: prevCoords.mouseDownPageX || 0, - mouseDownPageY: prevCoords.mouseDownPageY || 0, - }; - if ((prevCoords.buttons === 0 && e.buttons > 0) || e.pointerType === 'touch') { - result.mouseDownPageX = mousePageX; - result.mouseDownPageY = mousePageY; - } - return result; -} diff --git a/uui-core/src/services/dnd/DndRowsDataService.ts b/uui-core/src/services/dnd/DndRowsDataService.ts new file mode 100644 index 0000000000..ddbba0a878 --- /dev/null +++ b/uui-core/src/services/dnd/DndRowsDataService.ts @@ -0,0 +1,30 @@ +import { newMap } from '../../data'; +import { IDndData, IMap } from '../../types'; + +export interface DndRowData extends IDndData { + id: TId; + path: TId[]; + /** Source item data. This is the srcData of the actor that is being dropped into. */ + srcData: TSrcData; + /** Destination item data. This is the dstData of the actor into which the drop is performed. */ + dstData?: TDstData; +} + +export class DndRowsDataService { + dndRows: IMap>; + constructor() { + this.dndRows = newMap({ complexIds: true }); + } + + setDndRowData(data: DndRowData) { + this.dndRows.set(data.id, data); + } + + getDndRowData(id: TId) { + return this.dndRows.get(id) ?? null; + } + + destroy() { + this.dndRows = newMap({ complexIds: true }); + } +} diff --git a/uui-core/src/services/dnd/MouseCoordsService.ts b/uui-core/src/services/dnd/MouseCoordsService.ts new file mode 100644 index 0000000000..c67a593d47 --- /dev/null +++ b/uui-core/src/services/dnd/MouseCoordsService.ts @@ -0,0 +1,73 @@ +import { isClientSide } from '../../helpers/ssr'; + +export type TMouseCoords = { + mousePageX: number, + mousePageY: number, + mouseDx: number, + mouseDy: number, + mouseDxSmooth: number, + mouseDySmooth: number, + mouseDownPageX: number, + mouseDownPageY: number, + buttons: number, +}; + +export class MouseCoordsService { + private _prevMouseCoords: TMouseCoords; + + init = () => { + this._prevMouseCoords = { + mousePageX: 0, + mousePageY: 0, + mouseDx: 0, + mouseDy: 0, + mouseDxSmooth: 0, + mouseDySmooth: 0, + mouseDownPageX: 0, + mouseDownPageY: 0, + buttons: 0, + }; + if (isClientSide) { + document.addEventListener('pointermove', this.handleMouseCoordsChange); + } + }; + + public destroy() { + if (isClientSide) { + document.removeEventListener('pointermove', this.handleMouseCoordsChange); + } + } + + private handleMouseCoordsChange = (e: PointerEvent) => { + this._prevMouseCoords = getMouseCoordsFromPointerEvent(e, this._prevMouseCoords); + }; + + public getCoords = () => { + return this._prevMouseCoords; + }; +} + +function getMouseCoordsFromPointerEvent(e: PointerEvent, prevCoords: TMouseCoords): TMouseCoords { + const mouseDx = e.pageX - prevCoords.mousePageX; + const mouseDy = e.pageY - prevCoords.mousePageY; + const mouseDxSmooth = prevCoords.mouseDxSmooth * 0.8 + mouseDx * 0.2; + const mouseDySmooth = prevCoords.mouseDySmooth * 0.8 + mouseDy * 0.2; + const mousePageX = e.pageX; + const mousePageY = e.pageY; + const result: TMouseCoords = { + mouseDx, + mouseDy, + mouseDxSmooth, + mouseDySmooth, + mousePageX, + mousePageY, + buttons: e.buttons, + mouseDownPageX: prevCoords.mouseDownPageX || 0, + mouseDownPageY: prevCoords.mouseDownPageY || 0, + }; + if ((prevCoords.buttons === 0 && e.buttons > 0) || e.pointerType === 'touch') { + result.mouseDownPageX = mousePageX; + result.mouseDownPageY = mousePageY; + } + return result; +} diff --git a/uui-core/src/types/contexts.ts b/uui-core/src/types/contexts.ts index fcc7eaf7e5..3e540f13a2 100644 --- a/uui-core/src/types/contexts.ts +++ b/uui-core/src/types/contexts.ts @@ -1,7 +1,7 @@ import { AnalyticsEvent, Link } from './objects'; import * as PropTypes from 'prop-types'; import { IModal, INotification } from './props'; -import { DndContextState, TMouseCoords } from '../services/dnd/DndContext'; +import { DndContextState } from '../services/dnd/DndContext'; import { Lock } from '../services/LockContext'; import { IHistory4 } from '../services/routing/HistoryAdaptedRouter'; import { NotificationOperation } from '../services/NotificationContext'; @@ -9,6 +9,9 @@ import { ModalOperation } from '../services/ModalContext'; import { LayoutLayer } from '../services/LayoutContext'; import { FileUploadOptions, FileUploadResponse } from '../services/ApiContext'; +import { TMouseCoords } from '../services/dnd/MouseCoordsService'; +import { DndRowData } from '../services/dnd/DndRowsDataService'; +import { DropPosition } from './dnd'; export interface IBaseContext { /** Add your handler, which will be called on context updates */ @@ -102,13 +105,21 @@ export interface IModalContext extends IBaseContext { getOperations(): ModalOperation[]; } -export interface IDndContext extends IBaseContext { +export interface IDraggingOverInfo { + id: TId; + position: DropPosition; +} + +export interface IDndContext extends IBaseContext { startDrag(node: Node, data: any, renderGhost: () => React.ReactNode): void; endDrag(): void; + setDraggingOverInfo(info: IDraggingOverInfo): void; /** Indicates that drag in progress */ isDragging: boolean; dragData?: any; getMouseCoords: () => TMouseCoords + setDndRowData(data: DndRowData): void; + getDndRowData(id: TId): DndRowData; } /** Save data to the localStorage */ diff --git a/uui-core/src/types/dnd.ts b/uui-core/src/types/dnd.ts index fe55c79af6..7297f8b29c 100644 --- a/uui-core/src/types/dnd.ts +++ b/uui-core/src/types/dnd.ts @@ -4,11 +4,15 @@ export type DropPosition = 'top' | 'bottom' | 'left' | 'right' | 'inside'; export type DropPositionOptions = Partial>; -export interface AcceptDropParams { +export interface IDndData { /** Source item data. This is the srcData of the actor that is being dropped into. */ - srcData: TSrcData; + srcData?: TSrcData; /** Destination item data. This is the dstData of the actor into which the drop is performed. */ dstData?: TDstData; +} + +export interface AcceptDropParams extends Omit, 'srcData'> { + srcData: TSrcData; offsetLeft: number; offsetTop: number; targetWidth: number; @@ -20,6 +24,22 @@ export interface DropParams extends AcceptDropParams { + isRowCheckable?: boolean; + dropLevelsProps: DndDropLevelRenderProps[]; +} + +export interface DndDropLevelRenderProps { + id: TId; + level: number; + lastLevel: number; + key: string; + isDraggedOver?: boolean; + draggingOverLevel?: number | null; + onPointerEnter?: (e: React.PointerEvent) => void; + onPointerUp?: (e: React.PointerEvent) => void; +} + export interface DndActorRenderParams { /** True, if the element can be dragged. Doesn't mean that DnD is active. */ isDraggable: boolean; @@ -44,6 +64,7 @@ export interface DndActorRenderParams { /** Drop position. Chosen from accepted drop positions according to pointer coordinates */ position?: DropPosition; + isRowHighlighted?: boolean; /** * Event handlers. Component is expected to pass these events to the top element it renders. @@ -68,15 +89,7 @@ export interface DndActorRenderParams { ref?: React.Ref; } -export interface IDndActor { - /** Data used when this component acts as a drag source. - * If provided, it means this component can be dragged. Can be used in combination with dstData. - */ - srcData?: TSrcData; - /** Data used when this component acts as a drop destination. - * If provided, it means something can be dragged onto this component. Can be used in combination with srcData. - */ - dstData?: TDstData; +export interface IDndActor extends IDndData { /** A pure function that gets permitted positions for a drop action */ canAcceptDrop?(params: AcceptDropParams): DropPositionOptions | null; /** Called when accepted drop action performed on this actor. Usually used to reorder and update items */ diff --git a/uui-core/src/types/tables.ts b/uui-core/src/types/tables.ts index 7fdad16484..4fcafe9222 100644 --- a/uui-core/src/types/tables.ts +++ b/uui-core/src/types/tables.ts @@ -6,7 +6,7 @@ import { import { PickerBaseOptions } from './pickers'; import { DataRowProps } from './dataRows'; import { FilterPredicateName } from './dataQuery'; -import { DndActorRenderParams, DropParams } from './dnd'; +import { DndDropLevelRenderProps, DropParams } from './dnd'; import { DataSourceState, SortDirection, SortingOption, } from './dataSources'; @@ -177,6 +177,11 @@ export interface DataTableColumnsConfigOptions { allowColumnsResizing?: boolean; } +export interface DropLevelProps extends DndDropLevelRenderProps, IHasCX { + size?: string; + isRowCheckable: boolean; +} + export interface DataTableRowProps extends DataRowProps { /** Array of visible columns */ columns?: DataColumnProps[]; @@ -189,7 +194,7 @@ export interface DataTableRowProps extends DataRowProps< * Render callback for the drop marker. Rendered only if 'dnd' option was provided via getRowProps. * If omitted, default renderer will be used. * */ - renderDropMarkers?: (props: DndActorRenderParams) => ReactNode; + renderDropLevel?: (props: DropLevelProps) => ReactNode; } export interface RenderEditorProps extends IEditable, IHasValidationMessage, ICanFocus { diff --git a/uui/components/dnd/DropLevel.module.scss b/uui/components/dnd/DropLevel.module.scss new file mode 100644 index 0000000000..b58dd25703 --- /dev/null +++ b/uui/components/dnd/DropLevel.module.scss @@ -0,0 +1,14 @@ +.drop-level { + height: 100%; + margin-right: 5px; +} + + +.drop-level-dragging-over-row { + border-bottom: 3px solid; + border-color: var(--uui-info-50); +} + +.drop-level-active { + margin-right: 0; +} \ No newline at end of file diff --git a/uui/components/dnd/DropLevel.tsx b/uui/components/dnd/DropLevel.tsx new file mode 100644 index 0000000000..44c83d250b --- /dev/null +++ b/uui/components/dnd/DropLevel.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { FlexCell } from '@epam/uui-components'; +import { DropLevelProps } from '@epam/uui-core'; +import css from './DropLevel.module.scss'; + +export function DropLevel(props: DropLevelProps) { + const getIndent = (level: number) => { + switch (props.size) { + case '24': + return level * 6; + case '30': + case '36': + return level * 12; + case '42': + case '48': + case '60': + return level * 24; + default: + return level * 24; + } + }; + + const getFoldingWidth = () => { + switch (props.size) { + case '24': + return 12; + case '30': + case '36': + return 18; + case '42': + case '48': + case '60': + return 24; + default: + return 12; + } + }; + + const getCheckboxWidth = () => { + const additionalItemSize = +props.size < 30 ? 12 : 18; + return additionalItemSize; + }; + + const getDropLevelWidth = (level: number) => { + const foldingWidth = getFoldingWidth(); + if (level === 0) { + const checkboxWidth = props.isRowCheckable ? getCheckboxWidth() : 0; + return getIndent(1) + foldingWidth + checkboxWidth; + } + + return getIndent(1) + foldingWidth; + }; + + const isActiveLevel = props.isDraggedOver && props.draggingOverLevel !== null && props.level >= props.draggingOverLevel; + const width = props.level < props.lastLevel ? getDropLevelWidth(props.level) : '100%'; + + return ( + + + ); +} diff --git a/uui/components/dnd/index.tsx b/uui/components/dnd/index.tsx index 81f74d0e00..71d6700b78 100644 --- a/uui/components/dnd/index.tsx +++ b/uui/components/dnd/index.tsx @@ -1 +1,2 @@ export * from './DropMarker'; +export * from './DropLevel'; diff --git a/uui/components/tables/DataTableRow.module.scss b/uui/components/tables/DataTableRow.module.scss index dd9d8ade93..5b443ae1d7 100644 --- a/uui/components/tables/DataTableRow.module.scss +++ b/uui/components/tables/DataTableRow.module.scss @@ -14,7 +14,7 @@ } } - &:global(.-draggable) { + &:global(.-draggable):not(:global(.uui-drag-in-progress)) { @include dnd-cursor-style(); } @@ -31,7 +31,19 @@ } &:global(.uui-drag-ghost) { + opacity: 0.8; + cursor: grabbing; + + :global(.uui-drag-handle) { + visibility: visible; + } + @include dnd-ghost-shadow(); + @include dnd-cursor-style(); + } + + &:global(.uui-drag-in-progress) { + cursor: grabbing; } &:global(.uui-focus) { @@ -41,6 +53,10 @@ &:global(.uui-dt-row-border) { border-bottom-color: var(--uui-dt-border); } + + &:global(.uui-dropping-marker) { + --uui-dt-row-bg: var(--uui-dt-row-bg-dropping-capabilities); + } } :global(.uui-dt-row-border) { diff --git a/uui/components/tables/DataTableRow.tsx b/uui/components/tables/DataTableRow.tsx index 72fa4b36c4..40e64b8282 100644 --- a/uui/components/tables/DataTableRow.tsx +++ b/uui/components/tables/DataTableRow.tsx @@ -2,10 +2,11 @@ import * as React from 'react'; import { DataTableRow as uuiDataTableRow } from '@epam/uui-components'; import { withMods, DataTableCellProps, DndActorRenderParams, DataTableRowProps, + DropLevelProps, } from '@epam/uui-core'; import { DataTableCell } from './DataTableCell'; import { DataTableRowMods } from './types'; -import { DropMarker } from '../dnd'; +import { DropLevel, DropMarker } from '../dnd'; import css from './DataTableRow.module.scss'; import './variables.scss'; @@ -18,7 +19,12 @@ export const renderCell = (props: DataTableCellProps) => { export const renderDropMarkers = (props: DndActorRenderParams) => ; -export const propsMods = { renderCell, renderDropMarkers }; +export const renderDropLevel = (mods: DataTableRowMods) => + function RenderDropLevel(props: DropLevelProps) { + return ; + }; + +export const propsMods = (mods: DataTableRowMods) => ({ renderCell, renderDropLevel: renderDropLevel(mods) }); export const DataTableRow = withMods( uuiDataTableRow, @@ -27,5 +33,5 @@ export const DataTableRow = withMods( css.root, 'uui-dt-vars', borderBottom && 'uui-dt-row-border', css['size-' + (size || '36')], ]; }, - () => propsMods, + (mods) => propsMods(mods), ); diff --git a/uui/components/tables/columnsConfigurationModal/ColumnRow.tsx b/uui/components/tables/columnsConfigurationModal/ColumnRow.tsx index e445e75b45..0659713574 100644 --- a/uui/components/tables/columnsConfigurationModal/ColumnRow.tsx +++ b/uui/components/tables/columnsConfigurationModal/ColumnRow.tsx @@ -64,6 +64,7 @@ export const ColumnRow = React.memo(function ColumnRow(props: ColumnRowProps