diff --git a/src/BootstrapBlazor.Server/Components/Samples/TreeViews.razor b/src/BootstrapBlazor.Server/Components/Samples/TreeViews.razor
index 9658df02d79..4864578f4b7 100644
--- a/src/BootstrapBlazor.Server/Components/Samples/TreeViews.razor
+++ b/src/BootstrapBlazor.Server/Components/Samples/TreeViews.razor
@@ -52,6 +52,14 @@
+
+ @((MarkupString)Localizer["TreeViewDraggableDescription"].Value)
+
+
+
+
diff --git a/src/BootstrapBlazor.Server/Components/Samples/TreeViews.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/TreeViews.razor.cs
index d1601e7a9f1..90988a57ab6 100644
--- a/src/BootstrapBlazor.Server/Components/Samples/TreeViews.razor.cs
+++ b/src/BootstrapBlazor.Server/Components/Samples/TreeViews.razor.cs
@@ -3,6 +3,8 @@
// See the LICENSE file in the project root for more information.
// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone
+using DocumentFormat.OpenXml.Spreadsheet;
+
namespace BootstrapBlazor.Server.Components.Samples;
///
@@ -33,6 +35,8 @@ public sealed partial class TreeViews
private bool AutoCheckParent { get; set; }
+ private List> DraggableItems { get; set; } = [];
+
private List> DisabledItems { get; } = GetDisabledItems();
private List>? AccordionItems { get; } = TreeFoo.GetAccordionItems();
@@ -77,12 +81,61 @@ public sealed partial class TreeViews
private string? _selectedValue;
+ ///
+ ///
+ ///
+ protected override void OnInitialized()
+ {
+ base.OnInitialized();
+
+ var items = GetDraggableItems();
+ DraggableItems = TreeFoo.CascadingTree(items);
+ DraggableItems[0].IsExpand = true;
+ if (DraggableItems.Count > 1)
+ {
+ DraggableItems[1].IsExpand = true;
+ }
+ if (DraggableItems.Count > 2)
+ {
+ DraggableItems[2].IsExpand = true;
+ }
+ }
+
private Task OnTreeItemClick(TreeViewItem item)
{
Logger1.Log($"TreeItem: {item.Text} clicked");
return Task.CompletedTask;
}
+ private Task OnDragItemEndAsync(TreeViewDragContext context)
+ {
+ // 本例是使用静态数据模拟数据库操作的,实战中应该是更新节点的父级 Id 可能还需要更改排序字段等信息,然后重构 TreeView 数据源即可
+ // 根据 context 处理原始数据
+ var items = GetDraggableItems();
+ var source = items.Find(i => i.Id == context.Source.Value.Id);
+ if (source != null)
+ {
+ var target = items.Find(i => i.Id == context.Target.Value.Id);
+ if (target != null)
+ {
+ source.ParentId = context.IsChildren ? target.Id : target.ParentId;
+ }
+ }
+ DraggableItems = TreeFoo.CascadingTree(items);
+ DraggableItems[0].IsExpand = true;
+ if (DraggableItems.Count > 1)
+ {
+ DraggableItems[1].IsExpand = true;
+ }
+ if (DraggableItems.Count > 2)
+ {
+ DraggableItems[2].IsExpand = true;
+ }
+
+ StateHasChanged();
+ return Task.CompletedTask;
+ }
+
private Task OnTreeItemKeyboardClick(TreeViewItem item)
{
_selectedValue = item.Value.Text;
@@ -122,6 +175,28 @@ private Task OnTreeItemChecked(List> items)
return Task.CompletedTask;
}
+ private static List? _dragItems = null;
+ private static List GetDraggableItems()
+ {
+ _dragItems ??=
+ [
+ new() { Text = "Item A", Id = "1", Icon = "fa-solid fa-font-awesome" },
+ new() { Text = "Item D", Id = "4", ParentId = "1", Icon = "fa-solid fa-font-awesome" },
+ new() { Text = "Item E", Id = "5", ParentId = "1", Icon = "fa-solid fa-font-awesome" },
+
+ new() { Text = "Item B (Drop inside blocked)", Id = "2", Icon = "fa-solid fa-font-awesome" },
+ new() { Text = "Item F", Id = "6", ParentId = "2", Icon = "fa-solid fa-font-awesome" },
+ new() { Text = "Item G (Can not move out)", Id = "9", ParentId = "2", Icon = "fa-solid fa-font-awesome" },
+
+ new() { Text = "Item C", Id = "3", Icon = "fa-solid fa-font-awesome" },
+ new() { Text = "Item H", Id = "7", ParentId = "3", Icon = "fa-solid fa-font-awesome" },
+ new() { Text = "Item I", Id = "8", ParentId = "3", Icon = "fa-solid fa-font-awesome" },
+
+
+ ];
+ return _dragItems;
+ }
+
private static List> GetDisabledItems()
{
var ret = TreeFoo.GetTreeItems();
diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json
index 7173c0d90ba..d23e649bcd0 100644
--- a/src/BootstrapBlazor.Server/Locales/en-US.json
+++ b/src/BootstrapBlazor.Server/Locales/en-US.json
@@ -676,6 +676,9 @@
"TreeViewCheckboxCheckBoxDisplayText2": "Automatically select parent node",
"TreeViewCheckboxButtonText": "Refresh",
"TreeViewCheckboxAddButtonText": "Add",
+ "TreeViewDraggableTitle": "Draggable nodes",
+ "TreeViewDraggableIntro": "Allows nodes to be dragged and dropped in the tree control",
+ "TreeViewDraggableDescription": "By setting the AllowDrag property, you can drag and drop nodes in the tree control. Use the OnDragItemEndAsync callback delegate to handle the drop event.",
"TreeViewTreeDisableTitle": "Disabled state",
"TreeViewTreeDisableIntro": "Some nodes of the Tree can be set to disabled state",
"TreeViewTreeDisableDescription": "By setting the Disabled property of the data source TreeViewItem object, you can control whether this node can be checked or not. When set to false, it will not affect the node expansion. /shrink function",
diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json
index 934a0add740..8dae547ab56 100644
--- a/src/BootstrapBlazor.Server/Locales/zh-CN.json
+++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json
@@ -676,6 +676,9 @@
"TreeViewCheckboxCheckBoxDisplayText2": "自动选中父节点",
"TreeViewCheckboxButtonText": "刷新",
"TreeViewCheckboxAddButtonText": "追加节点",
+ "TreeViewDraggableTitle": "可拖拽节点",
+ "TreeViewDraggableIntro": "使树中的节点可以进行跨层级拖拽操作",
+ "TreeViewDraggableDescription": "通过设置 AllowDrag 属性开启节点拖拽功能,使用 OnDragItemEndAsync 回调委托方法响应拖拽节点放置事件",
"TreeViewTreeDisableTitle": "禁用状态",
"TreeViewTreeDisableIntro": "可将 Tree 的某些节点设置为禁用状态",
"TreeViewTreeDisableDescription": "通过设置数据源 TreeViewItem 对象的 Disabled 属性,来控制此节点是否可以进行勾选动作,设置为 false 时不影响节点展开/收缩功能",
diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj
index 0be606d0562..ed29bdae428 100644
--- a/src/BootstrapBlazor/BootstrapBlazor.csproj
+++ b/src/BootstrapBlazor/BootstrapBlazor.csproj
@@ -1,7 +1,7 @@
- 9.8.1-beta02
+ 9.8.1-beta03
diff --git a/src/BootstrapBlazor/Components/TreeView/TreeView.razor.cs b/src/BootstrapBlazor/Components/TreeView/TreeView.razor.cs
index 58a5c28161a..9872cf3b150 100644
--- a/src/BootstrapBlazor/Components/TreeView/TreeView.razor.cs
+++ b/src/BootstrapBlazor/Components/TreeView/TreeView.razor.cs
@@ -256,20 +256,6 @@ public partial class TreeView : IModelEqualityComparer
[Parameter]
public Func>? OnUpdateCallbackAsync { get; set; }
- [NotNull]
- private string? NotSetOnTreeExpandErrorMessage { get; set; }
-
- [Inject]
- [NotNull]
- private IStringLocalizer>? Localizer { get; set; }
-
- [Inject]
- [NotNull]
- private IIconTheme? IconTheme { get; set; }
-
- [NotNull]
- private TreeNodeCache, TItem>? _treeNodeStateCache = null;
-
///
/// Gets or sets whether to automatically update child nodes when the node state changes. Default is false.
///
@@ -282,12 +268,36 @@ public partial class TreeView : IModelEqualityComparer
[Parameter]
public bool AutoCheckParent { get; set; }
- private string? _searchText;
+ ///
+ /// Gets or sets a value indicating whether drag-and-drop operations are allowed. Default is false
+ ///
+ [Parameter]
+ public bool AllowDrag { get; set; }
+
+ ///
+ /// 获得/设置 拖动标签页结束回调方法
+ ///
+ [Parameter]
+ public Func, Task>? OnDragItemEndAsync { get; set; }
+
+ [Inject]
+ [NotNull]
+ private IStringLocalizer>? Localizer { get; set; }
+
+ [Inject]
+ [NotNull]
+ private IIconTheme? IconTheme { get; set; }
private string? EnableKeyboardString => EnableKeyboard ? "true" : null;
- private bool _shouldRender = true;
+ [NotNull]
+ private string? NotSetOnTreeExpandErrorMessage { get; set; }
+ [NotNull]
+ private TreeNodeCache, TItem>? _treeNodeStateCache = null;
+
+ private string? _searchText;
+ private bool _shouldRender = true;
private bool _init;
///
@@ -368,6 +378,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
_keyboardArrowUpDownTrigger = false;
await InvokeVoidAsync("scroll", Id, ScrollIntoViewOptions);
}
+
+ if(!firstRender && AllowDrag)
+ {
+ await InvokeVoidAsync("resetTreeViewRow", Id);
+ }
}
///
@@ -380,7 +395,13 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
///
///
///
- protected override Task InvokeInitAsync() => InvokeVoidAsync("init", Id, new { Invoke = Interop, Method = nameof(TriggerKeyDown) });
+ protected override Task InvokeInitAsync() => InvokeVoidAsync("init", Id, new
+ {
+ Invoke = Interop,
+ Method = nameof(TriggerKeyDown),
+ AllowDrag,
+ TriggerDragEnd = nameof(TriggerDragEnd)
+ });
private bool _keyboardArrowUpDownTrigger;
@@ -408,6 +429,30 @@ public async ValueTask TriggerKeyDown(string key)
}
}
+ ///
+ /// Triggers the end of a drag-and-drop operation within the tree view.
+ ///
+ /// This method is invoked via JavaScript interop to signal the completion of a drag-and-drop
+ /// action. If a handler is assigned to , it will be invoked with the drag
+ /// context.
+ /// The zero-based index of the item being dragged from its original position.
+ /// The zero-based index of the item's current position after the drag operation.
+ /// A value indicating whether the drag operation involves child items.
+ ///
+ [JSInvokable]
+ public async ValueTask TriggerDragEnd(int originIndex, int currentIndex, bool isChildren)
+ {
+ if (OnDragItemEndAsync != null)
+ {
+ var context = new TreeViewDragContext(
+ source: Rows[originIndex],
+ target: Rows[currentIndex],
+ children: isChildren
+ );
+ await OnDragItemEndAsync(context);
+ }
+ }
+
///
/// Client-side method to query the state of the specified row checkbox, called by JavaScript
///
diff --git a/src/BootstrapBlazor/Components/TreeView/TreeView.razor.js b/src/BootstrapBlazor/Components/TreeView/TreeView.razor.js
index 2d9499ef182..a7bc804b54e 100644
--- a/src/BootstrapBlazor/Components/TreeView/TreeView.razor.js
+++ b/src/BootstrapBlazor/Components/TreeView/TreeView.razor.js
@@ -1,4 +1,5 @@
import EventHandler from "../../modules/event-handler.js"
+import { insertBefore } from "../../modules/utility.js"
export function init(id, options) {
const el = document.getElementById(id)
@@ -6,7 +7,7 @@ export function init(id, options) {
return
}
- const { invoke, method } = options
+ const { invoke, method, allowDrag, triggerDragEnd } = options
EventHandler.on(el, 'keydown', '.tree-root', e => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const v = el.getAttribute('data-bb-keyboard');
@@ -26,6 +27,127 @@ export function init(id, options) {
}
}
});
+
+ if (allowDrag) {
+ resetTreeViewRow(id);
+
+ EventHandler.on(el, 'dragstart', e => {
+ el.targetItem = e.target;
+ el.targetItem.classList.add('drag-item');
+
+ e.dataTransfer.setData('text/plain', '');
+ e.dataTransfer.effectAllowed = 'move';
+ el.classList.add('dragging');
+ });
+
+ EventHandler.on(el, 'dragend', e => {
+ el.classList.remove('dragging');
+ el.targetItem.classList.remove('drag-item');
+
+ const item = el.targetItem.closest('.tree-content');
+ const originalIndex = parseInt(item.getAttribute("data-bb-tree-view-index"));
+
+ let isChildren = false;
+ let targetItem = null;
+ const overItem = el.querySelector('.tree-drag-inside-over');
+ if (overItem) {
+ overItem.classList.remove('tree-drag-inside-over');
+ isChildren = true;
+ targetItem = overItem.closest('.tree-content');
+ }
+ else {
+ const belowItem = el.querySelector('.tree-node-placeholder');
+ if (belowItem) {
+ targetItem = belowItem.closest('.tree-content');
+ belowItem.remove();
+ }
+ }
+ delete el.targetItem;
+
+ const currentIndex = parseInt(targetItem.getAttribute("data-bb-tree-view-index"));
+ invoke.invokeMethodAsync(triggerDragEnd, originalIndex, currentIndex, isChildren);
+ });
+
+ EventHandler.on(el, 'dragenter', '.tree-drop-child-inside', e => {
+ e.preventDefault();
+
+ const item = e.delegateTarget;
+ item.classList.add('tree-drag-inside-over');
+ });
+ EventHandler.on(el, 'dragenter', '.tree-drop-child-below', e => {
+ e.preventDefault()
+
+ const item = e.delegateTarget;
+ const placeholder = createPlaceholder();
+ item.appendChild(placeholder);
+ });
+
+ EventHandler.on(el, 'dragleave', '.tree-drop-child-inside', e => {
+ e.preventDefault()
+
+ const item = e.delegateTarget;
+ item.classList.remove('tree-drag-inside-over');
+ });
+ EventHandler.on(el, 'dragleave', '.tree-drop-child-below', e => {
+ e.preventDefault()
+
+ const item = e.delegateTarget;
+ item.classList.remove('tree-drag-below-over');
+ item.innerHTML = "";
+ });
+
+ EventHandler.on(el, 'dragover', '.tree-drop-zone', e => {
+ e.preventDefault()
+ });
+ }
+}
+
+export function resetTreeViewRow(id) {
+ const el = document.getElementById(id);
+ const rows = [...el.querySelectorAll('.tree-content')];
+ rows.forEach(row => {
+ const node = row.querySelector('.tree-node');
+ if (node) {
+ node.setAttribute('draggable', 'true');
+ const prevElement = node.previousElementSibling;
+ if (prevElement && !prevElement.classList.contains('tree-drop-zone')) {
+ const dropzone = createDropzone();
+ insertBefore(node, dropzone);
+ }
+ }
+ });
+}
+
+const createDropzone = () => {
+ const div = document.createElement('div');
+ div.classList.add(`tree-drop-zone`);
+
+ const inside = document.createElement('div');
+ inside.classList.add(`tree-drop-child-inside`);
+
+ const below = document.createElement('div');
+ below.classList.add(`tree-drop-child-below`);
+
+ div.appendChild(inside);
+ div.appendChild(below);
+
+ return div
+}
+
+const createPlaceholder = () => {
+ const div = document.createElement('div');
+ div.classList.add(`tree-node-placeholder`);
+
+ const circle = document.createElement('div');
+ circle.classList.add(`tree-node-ph-circle`);
+
+ const line = document.createElement('div');
+ line.classList.add(`tree-node-ph-line`);
+
+ div.appendChild(circle);
+ div.appendChild(line);
+
+ return div
}
export function scroll(id, options) {
@@ -113,5 +235,10 @@ export function dispose(id) {
if (el) {
EventHandler.off(el, 'keyup', '.tree-root');
+ EventHandler.off(el, 'dragstart');
+ EventHandler.off(el, 'dragend');
+ EventHandler.off(el, 'dragenter');
+ EventHandler.off(el, 'dragleave');
+ EventHandler.off(el, 'dragover');
}
}
diff --git a/src/BootstrapBlazor/Components/TreeView/TreeView.razor.scss b/src/BootstrapBlazor/Components/TreeView/TreeView.razor.scss
index 7774e60a701..089708a69c3 100644
--- a/src/BootstrapBlazor/Components/TreeView/TreeView.razor.scss
+++ b/src/BootstrapBlazor/Components/TreeView/TreeView.razor.scss
@@ -1,4 +1,4 @@
-.tree-view {
+.tree-view {
--bb-tree-padding: #{$bb-tree-padding};
--bb-tree-margin: #{$bb-tree-margin};
--bb-tree-padding-left: #{$bb-tree-padding-left};
@@ -38,6 +38,7 @@
flex-wrap: nowrap;
align-items: center;
cursor: pointer;
+ user-select: none;
.tree-content-toolbar {
display: none;
@@ -55,6 +56,7 @@
flex-shrink: 0;
align-items: center;
border-radius: var(--bs-border-radius);
+ position: relative;
}
.node-icon {
@@ -121,6 +123,7 @@
display: inline-flex;
align-items: center;
padding: var(--bb-tree-node-padding);
+ position: relative;
flex: 1;
.tree-icon {
@@ -166,3 +169,69 @@
}
}
}
+
+.tree-view {
+ --bb-tree-drop-preview-color: #{$bb-primary-color};
+
+ .tree-drop-zone {
+ position: absolute;
+ top: 0px;
+ left: 0;
+ right: 0;
+ bottom: -6px;
+ background-color: transparent;
+ display: grid;
+ grid-template-rows: 1fr 5px;
+ pointer-events: none;
+
+ .tree-drop-child-inside {
+ grid-row: 1 / 2;
+
+ &.tree-drag-inside-over {
+ border: solid 2px var(--bb-tree-drop-preview-color);
+ border-radius: var(--bs-border-radius);
+ }
+ }
+
+ .tree-drop-child-below {
+ grid-row: 2 / 3;
+ z-index: 2;
+ }
+
+ .tree-node-placeholder {
+ position: absolute;
+ bottom: 0px;
+ left: 18px;
+ right: 0;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ pointer-events: none;
+
+ .tree-node-ph-circle {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background-color: var(--bb-tree-drop-preview-color);
+ }
+
+ .tree-node-ph-line {
+ flex: 1;
+ min-width: 0px;
+ width: 1%;
+ height: 2px;
+ background-color: var(--bb-tree-drop-preview-color);
+ }
+ }
+ }
+
+ &.dragging {
+ .tree-drop-zone {
+ pointer-events: all;
+ }
+
+ .tree-node:not(.drag-item) {
+ pointer-events: none;
+ }
+ }
+}
diff --git a/src/BootstrapBlazor/Components/TreeView/TreeViewDragContext.cs b/src/BootstrapBlazor/Components/TreeView/TreeViewDragContext.cs
new file mode 100644
index 00000000000..8d04151ab91
--- /dev/null
+++ b/src/BootstrapBlazor/Components/TreeView/TreeViewDragContext.cs
@@ -0,0 +1,27 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License
+// See the LICENSE file in the project root for more information.
+// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone
+
+namespace BootstrapBlazor.Components;
+
+///
+/// 组件拖动上下文类
+///
+public class TreeViewDragContext(TreeViewItem source, TreeViewItem target, bool children = false)
+{
+ ///
+ /// 获得 源节点
+ ///
+ public TreeViewItem Source => source;
+
+ ///
+ /// 获得 目标节点
+ ///
+ public TreeViewItem Target => target;
+
+ ///
+ /// 获得 是否未目标节点的子节点
+ ///
+ public bool IsChildren => children;
+}
diff --git a/src/BootstrapBlazor/Components/TreeView/TreeViewRow.razor.cs b/src/BootstrapBlazor/Components/TreeView/TreeViewRow.razor.cs
index 39c5e453b13..a794b95272d 100644
--- a/src/BootstrapBlazor/Components/TreeView/TreeViewRow.razor.cs
+++ b/src/BootstrapBlazor/Components/TreeView/TreeViewRow.razor.cs
@@ -177,9 +177,7 @@ public partial class TreeViewRow
private bool IsPreventDefault => ContextMenuZone != null;
private bool _touchStart = false;
-
private bool _isBusy = false;
-
private bool _showToolbar = false;
///
diff --git a/test/UnitTest/Components/TreeViewTest.cs b/test/UnitTest/Components/TreeViewTest.cs
index 36f9c70bfe3..8649f58d873 100644
--- a/test/UnitTest/Components/TreeViewTest.cs
+++ b/test/UnitTest/Components/TreeViewTest.cs
@@ -1221,6 +1221,30 @@ public async Task ShowToolbar_Ok()
Assert.Contains("test-toolbar-template", cut.Markup);
}
+ [Fact]
+ public async Task AllowDrag_Ok()
+ {
+ TreeViewDragContext? treeDragContext = null;
+ var cut = Context.RenderComponent>(pb =>
+ {
+ pb.Add(a => a.AllowDrag, true);
+ pb.Add(a => a.Items, TreeFoo.GetTreeItems());
+ pb.Add(a => a.OnDragItemEndAsync, context =>
+ {
+ treeDragContext = context;
+ return Task.CompletedTask;
+ });
+ });
+
+ await cut.Instance.TriggerDragEnd(1, 2, false);
+ cut.SetParametersAndRender();
+
+ Assert.NotNull(treeDragContext);
+ Assert.NotNull(treeDragContext.Target);
+ Assert.NotNull(treeDragContext.Source);
+ Assert.False(treeDragContext.IsChildren);
+ }
+
class MockTree : TreeView where TItem : class
{
public bool TestComparerItem(TItem? a, TItem? b) => base.Equals(a, b);