Skip to content

Commit

Permalink
feat(ui5-table): implement drag and drop (#9955)
Browse files Browse the repository at this point in the history
This change introduces drag and drop capabilities on the Web Component Table V2.

To enable drag & drop, mark a ui5-table-row as movable and implement event listeners for the move and move-over events of the ui5-table.

Fixes #7240
  • Loading branch information
DonkeyCo authored Dec 11, 2024
1 parent 49dd5c6 commit 9f27a51
Show file tree
Hide file tree
Showing 17 changed files with 771 additions and 151 deletions.
25 changes: 25 additions & 0 deletions packages/base/src/util/dragAndDrop/DragRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type UI5Element from "../../UI5Element.js";
import type MovePlacement from "../../types/MovePlacement.js";

let draggedElement: HTMLElement | null = null;
let globalHandlersAttached = false;
Expand Down Expand Up @@ -75,6 +76,28 @@ const removeSelfManagedArea = (area: HTMLElement | ShadowRoot) => {
selfManagedDragAreas.delete(area);
};

type DragAndDropSettings = {
/**
* Allow cross-browser and file drag and drop.
*/
crossDnD?: boolean;
/**
* Pass the original event in the event parameters.
*/
originalEvent?: boolean;
};

type MoveEventDetail = {
originalEvent: Event,
source: {
element: HTMLElement,
},
destination: {
element: HTMLElement,
placement: `${MovePlacement}`,
}
};

const DragRegistry = {
subscribe,
unsubscribe,
Expand All @@ -86,4 +109,6 @@ const DragRegistry = {
export default DragRegistry;
export type {
SetDraggedElementFunction,
DragAndDropSettings,
MoveEventDetail,
};
63 changes: 63 additions & 0 deletions packages/base/src/util/dragAndDrop/handleDragOver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type UI5Element from "../../UI5Element.js";
import type MovePlacement from "../../types/MovePlacement.js";
import type { DragAndDropSettings } from "./DragRegistry.js";
import DragRegistry from "./DragRegistry.js";

type DragOverResult = {
targetReference: HTMLElement | null;
placement: any;
}

type DragPosition = {
element: HTMLElement;
placements: MovePlacement[];
};

/**
* Handles the dragover event.
*/
function handleDragOver<T extends UI5Element>(e: DragEvent, component: T, position: DragPosition, target: HTMLElement, settings: DragAndDropSettings = {}): DragOverResult {
const draggedElement = DragRegistry.getDraggedElement();
const dragOverResult: DragOverResult = {
targetReference: null,
placement: null,
};

if (!draggedElement && !settings?.crossDnD) {
return dragOverResult;
}

const placements = position.placements;
dragOverResult.targetReference = e.target as HTMLElement;

const placementAccepted = placements.some(placement => {
const originalEvent = settings.originalEvent ? { originalEvent: e } : {};
const beforeItemMovePrevented = !component.fireDecoratorEvent("move-over" as keyof T["eventDetails"], {
...originalEvent,
source: {
element: draggedElement,
},
destination: {
element: target,
placement,
},
} as T["eventDetails"][keyof T["eventDetails"]]);

if (beforeItemMovePrevented) {
e.preventDefault();
dragOverResult.targetReference = position.element;
dragOverResult.placement = placement;
return true;
}

return false;
});

if (!placementAccepted) {
dragOverResult.targetReference = null;
}

return dragOverResult;
}

export default handleDragOver;
29 changes: 29 additions & 0 deletions packages/base/src/util/dragAndDrop/handleDrop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type UI5Element from "../../UI5Element.js";
import type MovePlacement from "../../types/MovePlacement.js";
import type { DragAndDropSettings } from "./DragRegistry.js";
import DragRegistry from "./DragRegistry.js";

function handleDrop<T extends UI5Element>(e: DragEvent, component: T, target: HTMLElement, placement: `${MovePlacement}`, settings: DragAndDropSettings = {}): void {
e.preventDefault();
const draggedElement = DragRegistry.getDraggedElement();

if (!draggedElement && settings?.crossDnD) {
return;
}

const originalEvent = settings.originalEvent ? { originalEvent: e } : {};
component.fireDecoratorEvent("move" as keyof T["eventDetails"], {
...originalEvent,
source: {
element: draggedElement,
},
destination: {
element: target,
placement,
},
} as T["eventDetails"][keyof T["eventDetails"]]);

draggedElement?.focus();
}

export default handleDrop;
233 changes: 233 additions & 0 deletions packages/main/cypress/specs/TableDragAndDrop.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { html } from "lit";

import "../../src/Table.js";
import "../../src/TableHeaderRow.js";
import "../../src/TableCell.js";
import "../../src/TableRow.js";
import MovePlacement from "@ui5/webcomponents-base/dist/types/MovePlacement.js";

describe("API & Events", () => {
function dragTo(selectors: { source: string, destination: string }, position: MovePlacement, expectMove = true, onPrevented = false) {
const source = Cypress.$(selectors.source)[0];
const destination = Cypress.$(selectors.destination)[0];
const destinationRect = destination.getBoundingClientRect();

const dataTransfer = new DataTransfer();

cy.get(selectors.source)
.then(row => {
row.get(0).dispatchEvent(new DragEvent("dragstart", {
dataTransfer,
bubbles: true,
}));
});

let delta = 10;
if (position === MovePlacement.On) {
delta = destinationRect.height / 2;
} else if (position === MovePlacement.After) {
delta = destinationRect.height;
}

cy.get("ui5-table")
.trigger("dragover", {
dataTransfer,
clientX: destinationRect.left,
clientY: destinationRect.top + delta,
});

cy.get("ui5-table")
.trigger("drop", {
dataTransfer,
});

cy.get(selectors.source)
.trigger("dragend");

cy.get("@moveOver")
.should("be.called")
.should("be.calledWithMatch", {
detail: {
source: { element: source },
destination: { element: destination, placement: position },
},
});

if (onPrevented) {
position = MovePlacement.After;
}

if (expectMove) {
cy.get("@move")
.should("be.called")
.should("be.calledWithMatch", {
detail: {
source: { element: source },
destination: { element: destination, placement: position },
},
});
}
}

beforeEach(() => {
cy.viewport(1920, 1080);
cy.mount(html`
<ui5-table id="table">
<ui5-table-header-row slot="headerRow">
<ui5-table-header-cell><span>ColumnA</span></ui5-table-header-cell>
<ui5-table-header-cell><span>ColumnB</span></ui5-table-header-cell>
</ui5-table-header-row>
${Array.from({ length: 10 }).map((_, index) => html`
<ui5-table-row row-key="${index}" movable>
<ui5-table-cell><ui5-label>Cell A</ui5-label></ui5-table-cell>
<ui5-table-cell><ui5-label>Cell B</ui5-label></ui5-table-cell>
</ui5-table-row>
`)}
<ui5-table-row row-key="10">
<ui5-table-cell><ui5-label>Cell A</ui5-label></ui5-table-cell>
<ui5-table-cell><ui5-label>Cell B</ui5-label></ui5-table-cell>
</ui5-table-row>
</ui5-table>
`);
});

it("tests if draggable=true is set", () => {
cy.get("[ui5-table-row]")
.should("have.length", 11)
.each(($row, index) => {
if (index === 10) {
cy.wrap($row).should("not.have.attr", "draggable");
} else {
cy.wrap($row).should("have.attr", "draggable", "true");
}
});
});

it("tests if events are fired and paramters are as expected", () => {
cy.get("[ui5-table]")
.then(table => {
table.get(0).addEventListener("move-over", e => e.preventDefault());
table.get(0).addEventListener("move-over", cy.stub().as("moveOver"));
table.get(0).addEventListener("move", cy.stub().as("move"));
});

dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='1']",
}, MovePlacement.Before);
dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='1']",
}, MovePlacement.On);
dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='1']",
}, MovePlacement.After);
});

it("tests if drop with Before placement does not occur when not preventing move-over for it", () => {
cy.get("ui5-table")
.then(table => {
table.get(0).addEventListener("move-over", (e: Event) => {
const evt = e as CustomEvent; // needed to satisfy TS
if (evt.detail.destination.placement === MovePlacement.Before) {
return;
}
e.preventDefault();
});
table.get(0).addEventListener("move-over", cy.stub().as("moveOver"));
table.get(0).addEventListener("move", cy.stub().as("move"));
});

dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='5']",
}, MovePlacement.Before, false);
dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='5']",
}, MovePlacement.On);
dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='5']",
}, MovePlacement.After);
});

it("tests if drop with After placement does not occur when not preventing move-over for it", () => {
cy.get("ui5-table")
.then(table => {
table.get(0).addEventListener("move-over", (e: Event) => {
const evt = e as CustomEvent; // needed to satisfy TS
if (evt.detail.destination.placement === MovePlacement.After) {
return;
}
e.preventDefault();
});
table.get(0).addEventListener("move-over", cy.stub().as("moveOver"));
table.get(0).addEventListener("move", cy.stub().as("move"));
});

dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='5']",
}, MovePlacement.Before);

cy.get("@move")
.should("have.callCount", 1);

dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='5']",
}, MovePlacement.On);

cy.get("@move")
.should("have.callCount", 2);

dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='5']",
}, MovePlacement.After, false);

cy.get("@move")
.should("have.callCount", 2);
});

it("tests if drop with On placement does occur (because Before/After still applies) when not preventing move-over for it", () => {
cy.get("ui5-table")
.then(table => {
table.get(0).addEventListener("move-over", (e: Event) => {
const evt = e as CustomEvent; // needed to satisfy TS
if (evt.detail.destination.placement === MovePlacement.On) {
return;
}
e.preventDefault();
});
table.get(0).addEventListener("move-over", cy.stub().as("moveOver"));
table.get(0).addEventListener("move", cy.stub().as("move"));
});

dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='5']",
}, MovePlacement.Before);

cy.get("@move")
.should("have.callCount", 1);

dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='5']",
}, MovePlacement.On, true, true);

cy.get("@move")
.should("have.callCount", 2);

dragTo({
source: "ui5-table-row[row-key='0']",
destination: "ui5-table-row[row-key='5']",
}, MovePlacement.After);

cy.get("@move")
.should("have.callCount", 3);
});
});
Loading

0 comments on commit 9f27a51

Please sign in to comment.