Usage
Import and register the custom drag-enabled transfer component, then integrate it into your Vue template:
<CustomDragTransfer
:selected-right-items="selectedRightFilterList"
left-panel-title="Unavailable Filter Fields"
right-panel-title="Saved Filter Presets"
:all-source-data="fullFilterDataset"
:field-mapping="transferFieldMap"
@right-data-updated="updateSelectedFilters"
></CustomDragTransfer>
Component Props Explained
| Prop | Description |
|---|---|
selected-right-items |
Pre-selected dataset rendeerd in the right panel |
left-panel-title |
Custom display title for the left unselected list |
right-panel-title |
Custom display title for the right selected list |
all-source-data |
Complete source dataset shared between both panels |
field-mapping |
Object mapping the component's value and label fields for list items |
right-data-updated |
Emitted event that passes the updated right panel data to the parent component |
Parent Component Data Definition
export default {
data() {
return {
transferFieldMap: {
value: "rfidSetId",
label: "rfidSetName"
}
}
}
}
Full Component Implemantation
Create the component file at src/components/CustomDragTransfer.vue:
<template>
<div class="custom-drag-transfer">
<div class="transfer-container">
<!-- Left Panel -->
<div class="transfer-panel left-panel">
<div class="panel-header">
{{ leftPanelTitle }}
<span class="drag-hint">Drag fields to reorder or move between panels</span>
</div>
<div
id="left-panel-list"
class="transfer-list"
@drop="handleDrop"
@dragover="allowDrop"
>
<template v-for="(item, idx) in leftPanelData">
<div
:key="idx"
draggable="true"
@dragstart="handleDragStart($event, item)"
:data-item-id="item[fieldMapping.value]"
@click="toggleLeftItemSelection(item)"
:class="[
'transfer-list-item',
item.checked ? 'active-selection' : ''
]"
>
<input
class="item-checkbox"
type="checkbox"
v-model="item.checked"
@change="handleLeftCheckboxChange(item)"
/>
<span class="item-label">{{ item[fieldMapping.label] }}</span>
</div>
</template>
</div>
</div>
<!-- Middle Action Buttons -->
<div class="transfer-actions">
<img
src="@/assets/icons/arrow-right.svg"
alt="Move selected items to right panel"
@click="moveSelectedToRight"
class="action-btn"
/>
<img
src="@/assets/icons/arrow-left.svg"
alt="Move selected items to left panel"
@click="moveSelectedToLeft"
class="action-btn"
/>
</div>
<!-- Right Panel -->
<div class="transfer-panel right-panel">
<div class="panel-header">
{{ rightPanelTitle }}
</div>
<div
id="right-panel-list"
class="transfer-list"
@drop="handleDrop"
@dragover="allowDrop"
>
<template v-for="(item, idx) in rightPanelData">
<div
:key="idx"
draggable="true"
@dragstart="handleDragStart($event, item)"
:data-item-id="item[fieldMapping.value]"
@click="toggleRightItemSelection(item)"
:class="[
'transfer-list-item',
item.checked ? 'active-selection' : ''
]"
>
<input
class="item-checkbox"
type="checkbox"
v-model="item.checked"
@change="handleRightCheckboxChange(item)"
/>
<span class="item-label">{{ item[fieldMapping.label] }}</span>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
selectedRightItems: {
type: Array,
default: () => []
},
leftPanelTitle: {
type: String,
default: "Unselected Items"
},
rightPanelTitle: {
type: String,
default: "Selected Items"
},
allSourceData: {
type: Array,
default: () => []
},
fieldMapping: {
type: Object,
default: () => ({
value: "id",
label: "name"
})
}
},
data() {
return {
draggedItem: null,
leftPanelData: [],
rightPanelData: []
};
},
mounted() {
this.initializePanelData();
},
watch: {
allSourceData(newVal) {
this.initializePanelData();
},
selectedRightItems(newVal) {
this.syncRightPanelData(newVal);
}
},
methods: {
initializePanelData() {
const rightIds = new Set(this.selectedRightItems.map(item => item[this.fieldMapping.value]));
this.leftPanelData = this.allSourceData.filter(item => !rightIds.has(item[this.fieldMapping.value]));
this.rightPanelData = [...this.selectedRightItems];
},
syncRightPanelData(rightItems) {
const rightIds = new Set(rightItems.map(item => item[this.fieldMapping.value]));
this.leftPanelData = this.allSourceData.filter(item => !rightIds.has(item[this.fieldMapping.value]));
this.rightPanelData = [...rightItems];
},
allowDrop(e) {
e.preventDefault();
},
handleDragStart(e, item) {
this.draggedItem = item;
e.dataTransfer.setData("text/plain", JSON.stringify(item));
},
handleDrop(e) {
e.preventDefault();
const targetPanel = e.currentTarget.id === "left-panel-list" ? "left" : "right";
if (!this.draggedItem) return;
if (targetPanel === "left") {
this.moveItemToPanel(this.draggedItem, "left");
} else {
this.moveItemToPanel(this.draggedItem, "right");
}
this.draggedItem = null;
},
moveItemToPanel(item, targetPanel) {
const sourcePanel = this.leftPanelData.includes(item) ? "left" : "right";
if (sourcePanel === targetPanel) return;
// Remove from source panel
const sourceList = sourcePanel === "left" ? this.leftPanelData : this.rightPanelData;
const itemIndex = sourceList.findIndex(i => i[this.fieldMapping.value] === item[this.fieldMapping.value]);
if (itemIndex > -1) sourceList.splice(itemIndex, 1);
// Add to target panel
const targetList = targetPanel === "left" ? this.leftPanelData : this.rightPanelData;
targetList.push(item);
this.emitUpdatedData();
},
moveSelectedToRight() {
const selectedItems = this.leftPanelData.filter(item => item.checked);
selectedItems.forEach(item => this.moveItemToPanel(item, "right"));
this.clearAllChecked();
},
moveSelectedToLeft() {
const selectedItems = this.rightPanelData.filter(item => item.checked);
selectedItems.forEach(item => this.moveItemToPanel(item, "left"));
this.clearAllChecked();
},
toggleLeftItemSelection(item) {
item.checked = !item.checked;
},
toggleRightItemSelection(item) {
item.checked = !item.checked;
},
handleLeftCheckboxChange(item) {
this.$emit("checkbox-change", { item, panel: "left" });
},
handleRightCheckboxChange(item) {
this.$emit("checkbox-change", { item, panel: "right" });
},
clearAllChecked() {
this.leftPanelData.forEach(item => item.checked = false);
this.rightPanelData.forEach(item => item.checked = false);
},
emitUpdatedData() {
this.$emit("right-data-updated", [...this.rightPanelData]);
}
}
};
</script>
<style scoped>
.custom-drag-transfer {
padding: 16px;
font-family: system-ui, sans-serif;
}
.transfer-container {
display: flex;
gap: 24px;
align-items: center;
}
.transfer-panel {
flex: 1;
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
}
.panel-header {
padding: 12px;
background-color: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
font-weight: 500;
}
.drag-hint {
font-size: 12px;
color: #909399;
margin-left: 8px;
}
.transfer-list {
max-height: 400px;
overflow-y: auto;
padding: 8px;
}
.transfer-list-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
margin-bottom: 4px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.transfer-list-item:hover {
background-color: #f5f7fa;
}
.active-selection {
background-color: #ecf5ff;
border-color: #d9ecff;
}
.item-checkbox {
width: 16px;
height: 16px;
}
.item-label {
flex: 1;
font-size: 14px;
}
.transfer-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
cursor: pointer;
transition: opacity 0.2s;
}
.action-btn:hover {
opacity: 0.8;
}
</style>