Skip to content

feat(ui5-list): enable drag and drop for multiple items #11445

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
56 changes: 56 additions & 0 deletions packages/main/src/List.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import type {
import type DropIndicator from "./DropIndicator.js";
import type ListItem from "./ListItem.js";
import type {
MoveStartEventDetail,
SelectionRequestEventDetail,
} from "./ListItem.js";
import ListSeparator from "./types/ListSeparator.js";
Expand All @@ -70,6 +71,7 @@ import {
LOAD_MORE_TEXT, ARIA_LABEL_LIST_SELECTABLE,
ARIA_LABEL_LIST_MULTISELECTABLE,
ARIA_LABEL_LIST_DELETABLE,
LIST_DRAG_GHOST_TEXT,
} from "./generated/i18n/i18n-defaults.js";
import type CheckBox from "./CheckBox.js";
import type RadioButton from "./RadioButton.js";
Expand Down Expand Up @@ -112,6 +114,9 @@ type ListItemClickEventDetail = {

type ListMoveEventDetail = MoveEventDetail;

// Specific template type for the drag ghost
type ListDragElementTemplate = (this: List) => JSX.Element;

/**
* @class
*
Expand Down Expand Up @@ -299,6 +304,17 @@ class List extends UI5Element {
"move-over": ListMoveEventDetail,
"move": ListMoveEventDetail,
}

/**
* Defines the number of dragged items. Use this property to indicate that multiple items are being dragged.
* When a value greater than 1 is set, the component will display a custom ghost element that displays the number of dragged items.
* @default 0
* @public
* @since 2.10.0
*/
@property({ type: Number })
movingItemsCount = 0;

/**
* Defines the component header text.
*
Expand Down Expand Up @@ -473,6 +489,13 @@ class List extends UI5Element {
@property()
mediaRange = "S";

/**
* The custom template for the drag ghost element.
* @private
*/
@property({ noAttribute: true })
dragElementTemplate?: ListDragElementTemplate;

/**
* Defines the items of the component.
*
Expand Down Expand Up @@ -578,6 +601,18 @@ class List extends UI5Element {
onBeforeRendering() {
this.detachGroupHeaderEvents();
this.prepareListItems();

if (this.showDragGhost) {
// If feature is already loaded (preloaded by the user via importing ListItemStandardExpandableText.js), the template is already available
if (List.ListDragElementTemplate) {
this.dragElementTemplate = List.ListDragElementTemplate;
// If feature is not preloaded, load the template dynamically
} else {
import("./features/ListDragElementTemplate.js").then(module => {
this.dragElementTemplate = module.default;
});
}
}
}

onAfterRendering() {
Expand Down Expand Up @@ -615,6 +650,18 @@ class List extends UI5Element {
});
}

get dragGhost() {
return this.shadowRoot!.querySelector<UI5Element>(".ui5-list-drag-ghost");
}

get showDragGhost() {
return this.movingItemsCount > 1;
}

get dragGhostText() {
return List.i18nBundle.getText(LIST_DRAG_GHOST_TEXT, this.movingItemsCount);
}

get shouldRenderH1() {
return !this.header.length && this.headerText;
}
Expand Down Expand Up @@ -1214,6 +1261,13 @@ class List extends UI5Element {
return afterElement && afterElement.id === elementId;
}

onItemMoveStart(e: CustomEvent<MoveStartEventDetail>) {
const originalEvent = e.detail.originalEvent;
if (this.dragGhost && originalEvent.dataTransfer) {
originalEvent.dataTransfer.setDragImage(this.dragGhost, 0, 0);
}
}

onItemTabIndexChange(e: CustomEvent) {
e.stopPropagation();
const target = e.target as ListItemBase;
Expand Down Expand Up @@ -1430,6 +1484,8 @@ class List extends UI5Element {

return this.growingIntersectionObserver;
}

static ListDragElementTemplate?: ListDragElementTemplate;
}

List.define();
Expand Down
11 changes: 11 additions & 0 deletions packages/main/src/ListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ type SelectionRequestEventDetail = {
key?: string,
}

type MoveStartEventDetail = {
originalEvent: DragEvent,
}

type AccInfo = {
role?: AriaRole | undefined;
ariaExpanded?: boolean;
Expand Down Expand Up @@ -97,10 +101,14 @@ type ListItemAccessibilityAttributes = Pick<AccessibilityAttributes, "hasPopup"
@event("selection-requested", {
bubbles: true,
})
@event("move-start", {
bubbles: true,
})
abstract class ListItem extends ListItemBase {
eventDetails!: ListItemBase["eventDetails"] & {
"detail-click": { item: ListItem, selected: boolean };
"selection-requested": SelectionRequestEventDetail,
"move-start": MoveStartEventDetail,
}
/**
* Defines the visual indication and behavior of the list items.
Expand Down Expand Up @@ -333,6 +341,8 @@ abstract class ListItem extends ListItemBase {
this.setAttribute("data-moving", "");
e.dataTransfer.dropEffect = "move";
e.dataTransfer.effectAllowed = "move";

this.fireDecoratorEvent("move-start", { originalEvent: e });
}
}

Expand Down Expand Up @@ -522,4 +532,5 @@ export type {
IAccessibleListItem,
SelectionRequestEventDetail,
ListItemAccessibilityAttributes,
MoveStartEventDetail,
};
4 changes: 3 additions & 1 deletion packages/main/src/ListItemGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ type ListItemGroupMoveEventDetail = {
destination: {
element: HTMLElement,
placement: `${MovePlacement}`,
}
},
originalEvent?: DragEvent,
}

/**
Expand Down Expand Up @@ -228,6 +229,7 @@ class ListItemGroup extends UI5Element {
element: this.dropIndicatorDOM!.targetReference!,
placement: this.dropIndicatorDOM!.placement,
},
originalEvent: e,
});

this.dropIndicatorDOM!.targetReference = null;
Expand Down
7 changes: 7 additions & 0 deletions packages/main/src/ListTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default function ListTemplate(this: List) {
onDrop={this._ondrop}
onDragLeave={this._ondragleave}
// events bubbling from slotted items
onui5-move-start={this.onItemMoveStart}
onui5-close={this.onItemClose}
onui5-toggle={this.onItemToggle}
onui5-request-tabindex-change={this.onItemTabIndexChange}
Expand Down Expand Up @@ -53,6 +54,8 @@ export default function ListTemplate(this: List) {
>
<slot></slot>

{this.showDragGhost && dragGhost.call(this)}

{this.showNoDataText &&
<li tabindex={0} id={`${this._id}-nodata`} class="ui5-list-nodata" role="listitem">
<div id={`${this._id}-nodata-text`} class="ui5-list-nodata-text">
Expand Down Expand Up @@ -112,3 +115,7 @@ function moreRow(this: List) {
</div>
);
}

function dragGhost(this: List) {
return this.dragElementTemplate?.call(this);
}
5 changes: 5 additions & 0 deletions packages/main/src/features/ListDragElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import List from "../List.js";
import ListDragElementTemplate from "./ListDragElementTemplate.js";

// Assigns the template to the component to be used when the feature is preloaded
List.ListDragElementTemplate = ListDragElementTemplate;
20 changes: 20 additions & 0 deletions packages/main/src/features/ListDragElementTemplate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type List from "../List.js";
import ListItemStandard from "../ListItemStandard.js";

/**
* Provides a template for rendering the drag ghost text in the List component
* when multiple items are dragged.
*
* @returns {JSX.Element} The rendered drag ghost
*/
export default function ListDragElementTemplate(
this: List,
): JSX.Element {
return (
<div aria-hidden="true">
<ListItemStandard data-custom-drag-ghost class="ui5-list-drag-ghost">
{this.dragGhostText}
</ListItemStandard>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/main/src/i18n/messagebundle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ LIST_ITEM_NOT_SELECTED=Not Selected
#XACT: ARIA announcement for the list item selected=false state
LIST_ITEM_GROUP_HEADER=Group Header

LIST_DRAG_GHOST_TEXT={0} items

#XACT: ARIA announcement for grouping with role=list
LIST_ROLE_LIST_GROUP_DESCRIPTION=contains {0} sub groups with {1} items

Expand Down
6 changes: 6 additions & 0 deletions packages/main/src/themes/DraggableElement.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,10 @@
[draggable=true][data-moving] {
cursor: grabbing !important;
opacity: var(--sapContent_DisabledOpacity);
}

[data-custom-drag-ghost] {
position: absolute;
transform: translate(-10000px);
width: 100%;
}
1 change: 1 addition & 0 deletions packages/main/src/themes/List.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@import "./InvisibleTextStyles.css";
@import "./GrowingButton.css";
@import "./DraggableElement.css";

:host(:not([hidden])) {
display: block;
Expand Down
88 changes: 79 additions & 9 deletions packages/main/test/pages/ListDragAndDrop.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

<script src="%VITE_BUNDLE_PATH%" type="module"></script>
<link rel="stylesheet" type="text/css" href="./styles/List.css">

<style>
ui5-li-group ui5-li {
border: 1px solid red;
}
</style>
</head>

<body class="list1auto">
Expand Down Expand Up @@ -46,6 +52,32 @@ <h2>Drag and drop</h2>
</ui5-list>
</section>

<section class="largeTopMargin">
<div style="display: flex; gap: 1rem">
<ui5-list id="listMultipleDnd" selection-mode="Multiple" header-text="Available Options">
<ui5-li movable>Sunroof</ui5-li>
<ui5-li movable>Navigation</ui5-li>

<ui5-li-group id="group1" header-text="Seats" data-allows-nesting>
<ui5-li movable>Leather</ui5-li>
<ui5-li movable>Heated</ui5-li>
<ui5-li movable>Massage</ui5-li>
</ui5-li-group>

<ui5-li-group id="group2" header-text="Headlights" data-allows-nesting>
<ui5-li movable>LED</ui5-li>
<ui5-li movable>Matrix</ui5-li>
<ui5-li movable>Laser</ui5-li>
</ui5-li-group>
</ui5-list>

<ui5-list id="listMultipleDnd1" selection-mode="Multiple" header-text="Selected Options">
<ui5-li movable>Ceramic Brakes</ui5-li>
<ui5-li movable>Tinted Windows</ui5-li>
</ui5-list>
</div>
</section>

<script>
const list1 = document.getElementById("listDnd1");
const list2 = document.getElementById("listDnd2");
Expand Down Expand Up @@ -92,15 +124,15 @@ <h2>Drag and drop</h2>
const { destination, source } = e.detail;

switch (destination.placement) {
case "Before":
destination.element.before(source.element);
break;
case "After":
destination.element.after(source.element);
break;
case "On":
destination.element.prepend(source.element);
break;
case "Before":
destination.element.before(source.element);
break;
case "After":
destination.element.after(source.element);
break;
case "On":
destination.element.prepend(source.element);
break;
}
};

Expand All @@ -122,6 +154,44 @@ <h2>Drag and drop</h2>
list2.items.forEach((item) => item.movable = e.target.checked);
list3.items.forEach((item) => item.movable = e.target.checked);
});

// Multiple Selection
const listMultipleDnd = document.getElementById("listMultipleDnd");
const listMultipleDnd1 = document.getElementById("listMultipleDnd1");
const group1 = document.getElementById("group1");
const group2 = document.getElementById("group2");
let selectedItems = [];

function updateMovingItemsCount() {
listMultipleDnd.movingItemsCount = listMultipleDnd.getSelectedItems().length;
listMultipleDnd1.movingItemsCount = listMultipleDnd1.getSelectedItems().length;
}

const onMove = async (e) => {
const { destination, source, originalEvent } = e.detail;
const movedElements = selectedItems.length > 1 ? selectedItems : [source.element];
originalEvent?.stopPropagation();

if (destination.placement === "Before") {
destination.element.before(...movedElements);
} else if (destination.placement === "After") {
destination.element.after(...movedElements);
} else if (destination.placement === "On") {
destination.element.prepend(...movedElements);
}
await window["sap-ui-webcomponents-bundle"].renderFinished();
updateMovingItemsCount();
};

[group1, group2, listMultipleDnd1, listMultipleDnd].forEach((el) => {
if (el.tagName === "UI5-LIST") {
el.addEventListener("dragstart", () => selectedItems = el.getSelectedItems());
el.addEventListener("ui5-selection-change", updateMovingItemsCount);
el.addEventListener("ui5-move-over", handleBeforeItemMove);
}

el.addEventListener("ui5-move", onMove);
});
</script>
</body>

Expand Down
6 changes: 6 additions & 0 deletions packages/website/docs/_components_pages/main/List/List.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import GroupHeaders from "../../../_samples/main/List/GroupHeaders/GroupHeaders.
import SeparationTypes from "../../../_samples/main/List/SeparationTypes/SeparationTypes.md";
import DragAndDrop from "../../../_samples/main/List/DragAndDrop/DragAndDrop.md";
import WrappingBehavior from "../../../_samples/main/List/WrappingBehavior/WrappingBehavior.md";
import DragAndDropMultiple from "../../../_samples/main/List/DragAndDropMultiple/DragAndDropMultiple.md";

<%COMPONENT_OVERVIEW%>

Expand Down Expand Up @@ -57,6 +58,11 @@ The list items are draggable through the use of the <b>movable</b> property on <

<DragAndDrop />

### Drag And Drop of Multiple Items
You can show a multiple drag image by using the <b>movingItemsCount</b> property on <b>List</b>.

<DragAndDropMultiple />

### Wrapping Behavior
The standard list item `<ui5-li>` supports text wrapping through the <b>wrappingType</b> property. When set to "Normal", long text content (title and description) will wrap to multiple lines instead of truncating with an ellipsis. For very long content, the text is displayed with a "Show More/Show Less" mechanism.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import html from '!!raw-loader!./sample.html';
import js from '!!raw-loader!./main.js';

<Editor html={html} js={js} />
Loading
Loading