From 84386e81648795a882dd54f6cd539fc99428daf2 Mon Sep 17 00:00:00 2001 From: Russell Camo Date: Thu, 5 Mar 2026 16:38:08 +0800 Subject: [PATCH 1/5] feat(Sortable): add core sortable component with drag-and-drop functionality --- .../Components/Sortable/BbSortable.razor | 96 +++++++++++++++++++ .../Components/Sortable/BbSortable.razor.css | 7 ++ .../wwwroot/js/sortable.js | 34 +++++++ .../wwwroot/lib/sortable/sortable.min.js | 2 + 4 files changed, 139 insertions(+) create mode 100644 src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor create mode 100644 src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor.css create mode 100644 src/BlazorBlueprint.Components/wwwroot/js/sortable.js create mode 100644 src/BlazorBlueprint.Components/wwwroot/lib/sortable/sortable.min.js diff --git a/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor b/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor new file mode 100644 index 00000000..33a4e8c7 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor @@ -0,0 +1,96 @@ +@using System.Diagnostics.CodeAnalysis + +@implements IAsyncDisposable + +@inject IJSRuntime JsRuntime + +@typeparam T + +
+ @foreach (var item in Items) + { + @if (SortableItemTemplate is not null) + { + @SortableItemTemplate(item) + } + } +
+ +@code { + + private IJSObjectReference? _module; + + [Parameter] + public RenderFragment? SortableItemTemplate { get; set; } + + [Parameter, AllowNull] + public List Items { get; set; } + + [Parameter] + public EventCallback<(int oldIndex, int newIndex)> OnUpdate { get; set; } + + [Parameter] + public EventCallback<(int oldIndex, int newIndex)> OnRemove { get; set; } + + [Parameter] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + [Parameter] + public string Group { get; set; } = Guid.NewGuid().ToString(); + + [Parameter] + public string? Pull { get; set; } + + [Parameter] + public bool Put { get; set; } = true; + + [Parameter] + public bool Sort { get; set; } = true; + + [Parameter] + public string Handle { get; set; } = string.Empty; + + [Parameter] + public string? Filter { get; set; } + + [Parameter] + public bool ForceFallback { get; set; } = true; + + private DotNetObjectReference>? _ref; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _ref = DotNetObjectReference.Create(this); + _module = await JsRuntime.InvokeAsync("import", "./_content/BlazorBlueprint.Components/js/sortable.js"); + await _module.InvokeAsync("init", Id, Group, Pull, Put, Sort, Handle, Filter, _ref); + } + } + + [JSInvokable] + public void OnUpdateJS(int oldIndex, int newIndex) + { + // invoke the OnUpdate event passing in the oldIndex and the newIndex + OnUpdate.InvokeAsync((oldIndex, newIndex)); + } + + [JSInvokable] + public void OnRemoveJS(int oldIndex, int newIndex) + { + // remove the item from the list + OnRemove.InvokeAsync((oldIndex, newIndex)); + } + + public void Dispose() => _ref?.Dispose(); + + public async ValueTask DisposeAsync() + { + _ref?.Dispose(); + if (_module != null) + { + await _module.DisposeAsync(); + } + } + +} diff --git a/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor.css b/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor.css new file mode 100644 index 00000000..7ba5707d --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor.css @@ -0,0 +1,7 @@ +::deep .sortable-ghost { + visibility: hidden; +} + +::deep .sortable-fallback { + opacity: 1 !important +} diff --git a/src/BlazorBlueprint.Components/wwwroot/js/sortable.js b/src/BlazorBlueprint.Components/wwwroot/js/sortable.js new file mode 100644 index 00000000..fad8a90b --- /dev/null +++ b/src/BlazorBlueprint.Components/wwwroot/js/sortable.js @@ -0,0 +1,34 @@ +export function init(id, group, pull, put, sort, handle, filter, component, forceFallback) { + var sortable = new Sortable(document.getElementById(id), { + animation: 200, + group: { + name: group, + pull: pull || true, + put: put + }, + filter: filter || undefined, + sort: sort, + forceFallback: forceFallback, + handle: handle || undefined, + onUpdate: (event) => { + // Revert the DOM to match the .NET state + event.item.remove(); + event.to.insertBefore(event.item, event.to.children[event.oldIndex]); + + // Notify .NET to update its model and re-render + component.invokeMethodAsync('OnUpdateJS', event.oldDraggableIndex, event.newDraggableIndex); + }, + onRemove: (event) => { + if (event.pullMode === 'clone') { + // Remove the clone + event.clone.remove(); + } + + event.item.remove(); + event.from.insertBefore(event.item, event.from.childNodes[event.oldIndex]); + + // Notify .NET to update its model and re-render + component.invokeMethodAsync('OnRemoveJS', event.oldDraggableIndex, event.newDraggableIndex); + } + }); +} diff --git a/src/BlazorBlueprint.Components/wwwroot/lib/sortable/sortable.min.js b/src/BlazorBlueprint.Components/wwwroot/lib/sortable/sortable.min.js new file mode 100644 index 00000000..4fe7f0c3 --- /dev/null +++ b/src/BlazorBlueprint.Components/wwwroot/lib/sortable/sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.13.0 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e"===e[0]&&(e=e.substring(1)),t)try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return!1}return!1}}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"===e[0]?t.parentNode===n&&h(t,e):h(t,e))||o&&t===n)return t;if(t===n)break}while(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode)}var i;return null}var f,p=/\s+/g;function k(t,e,n){if(t&&e)if(t.classList)t.classList[n?"add":"remove"](e);else{var o=(" "+t.className+" ").replace(p," ").replace(" "+e+" "," ");t.className=(o+(n?" "+e:"")).replace(p," ")}}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];e in o||-1!==e.indexOf("webkit")||(e="-webkit-"+e),o[e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform");o&&"none"!==o&&(n=o+" "+n)}while(!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function g(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=e.left-n&&r<=e.right+n,i=a>=e.top-n&&a<=e.bottom+n;return n&&o&&i?l=t:void 0}}),l}((t=t.touches?t.touches[0]:t).clientX,t.clientY);if(e){var n={};for(var o in t)t.hasOwnProperty(o)&&(n[o]=t[o]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[j]._onDragOver(n)}}}function kt(t){z&&z.parentNode[j]._isOutsideThisEl(t.target)}function Rt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[j]=this;var n={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Ot(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Rt.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(var o in O.initializePlugins(this,t,n),n)o in e||(e[o]=n[o]);for(var i in Nt(e),this)"_"===i.charAt(0)&&"function"==typeof this[i]&&(this[i]=this[i].bind(this));this.nativeDraggable=!e.forceFallback&&xt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?d(t,"pointerdown",this._onTapStart):(d(t,"mousedown",this._onTapStart),d(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(d(t,"dragover",this),d(t,"dragenter",this)),bt.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,T())}function Xt(t,e,n,o,i,r,a,l){var s,c,u=t[j],d=u.options.onMove;return!window.CustomEvent||w||E?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),d&&(c=d.call(u,s,a)),c}function Yt(t){t.draggable=!1}function Bt(){Dt=!1}function Ft(t){for(var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;n--;)o+=e.charCodeAt(n);return o.toString(36)}function Ht(t){return setTimeout(t,0)}function Lt(t){return clearTimeout(t)}Rt.prototype={constructor:Rt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(ht=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(function(t){St.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&St.push(o)}}(o),!z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||Z===l)){if(J=F(l),et=F(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return W({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),K("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c&&(c=c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return W({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),K("filter",n,{evt:e}),!0})))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;if(n&&!z&&n.parentNode===r){var s=X(n);if(q=r,G=(z=n).parentNode,V=z.nextSibling,Z=n,ot=a.group,rt={target:Rt.dragged=z,clientX:(e||t).clientX,clientY:(e||t).clientY},ct=rt.clientX-s.left,ut=rt.clientY-s.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,z.style["will-change"]="all",o=function(){K("delayEnded",i,{evt:t}),Rt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!c&&i.nativeDraggable&&(z.draggable=!0),i._triggerDragStart(t,e),W({sortable:i,name:"choose",originalEvent:t}),k(z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){g(z,t.trim(),Yt)}),d(l,"dragover",Pt),d(l,"mousemove",Pt),d(l,"touchmove",Pt),d(l,"mouseup",i._onDrop),d(l,"touchend",i._onDrop),d(l,"touchcancel",i._onDrop),c&&this.nativeDraggable&&(this.options.touchStartThreshold=4,z.draggable=!0),K("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(E||w))o();else{if(Rt.eventCanceled)return void this._onDrop();d(l,"mouseup",i._disableDelayedDrag),d(l,"touchend",i._disableDelayedDrag),d(l,"touchcancel",i._disableDelayedDrag),d(l,"mousemove",i._delayedDragTouchMoveHandler),d(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&d(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)}}},_delayedDragTouchMoveHandler:function(t){var e=t.touches?t.touches[0]:t;Math.max(Math.abs(e.clientX-this._lastX),Math.abs(e.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){z&&Yt(z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;s(t,"mouseup",this._disableDelayedDrag),s(t,"touchend",this._disableDelayedDrag),s(t,"touchcancel",this._disableDelayedDrag),s(t,"mousemove",this._delayedDragTouchMoveHandler),s(t,"touchmove",this._delayedDragTouchMoveHandler),s(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?d(document,"pointermove",this._onTouchMove):d(document,e?"touchmove":"mousemove",this._onTouchMove):(d(z,"dragend",this),d(q,"dragstart",this._onDragStart));try{document.selection?Ht(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){if(vt=!1,q&&z){K("dragStarted",this,{evt:e}),this.nativeDraggable&&d(document,"dragover",kt);var n=this.options;t||k(z,n.dragClass,!1),k(z,n.ghostClass,!0),Rt.active=this,t&&this._appendGhost(),W({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(at){this._lastX=at.clientX,this._lastY=at.clientY,At();for(var t=document.elementFromPoint(at.clientX,at.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(at.clientX,at.clientY))!==e;)e=t;if(z.parentNode[j]._isOutsideThisEl(t),e)do{if(e[j]){if(e[j]._onDragOver({clientX:at.clientX,clientY:at.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}t=e}while(e=e.parentNode);It()}},_onTouchMove:function(t){if(rt){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=U&&v(U,!0),a=U&&r&&r.a,l=U&&r&&r.d,s=Ct&>&&b(gt),c=(i.clientX-rt.clientX+o.x)/(a||1)+(s?s[0]-Et[0]:0)/(a||1),u=(i.clientY-rt.clientY+o.y)/(l||1)+(s?s[1]-Et[1]:0)/(l||1);if(!Rt.active&&!vt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))o.right+10||t.clientX<=o.right&&t.clientY>o.bottom&&t.clientX>=o.left:t.clientX>o.right&&t.clientY>o.top||t.clientX<=o.right&&t.clientY>o.bottom+10}(n,a,this)&&!g.animated){if(g===z)return N(!1);if(g&&l===n.target&&(s=g),s&&(i=X(s)),!1!==Xt(q,l,z,o,s,i,n,!!s))return O(),l.appendChild(z),G=l,A(),N(!0)}else if(s.parentNode===l){i=X(s);var v,m,b,y=z.parentNode!==l,w=!function(t,e,n){var o=n?t.left:t.top,i=n?t.right:t.bottom,r=n?t.width:t.height,a=n?e.left:e.top,l=n?e.right:e.bottom,s=n?e.width:e.height;return o===a||i===l||o+r/2===a+s/2}(z.animated&&z.toRect||o,s.animated&&s.toRect||i,a),E=a?"top":"left",D=Y(s,"top","top")||Y(z,"top","top"),S=D?D.scrollTop:void 0;if(ht!==s&&(m=i[E],yt=!1,wt=!w&&e.invertSwap||y),0!==(v=function(t,e,n,o,i,r,a,l){var s=o?t.clientY:t.clientX,c=o?n.height:n.width,u=o?n.top:n.left,d=o?n.bottom:n.right,h=!1;if(!a)if(l&&pt Date: Thu, 5 Mar 2026 08:43:41 +0000 Subject: [PATCH 2/5] Initial plan From c47cf01272538eda87d802bef6c15cb0e01f7462 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:07:46 +0000 Subject: [PATCH 3/5] feat(sortable): refactor BbSortable, add WAI-ARIA, OnAdd event, grid layout, and comprehensive demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract BbSortable code to BbSortable.razor.cs (partial class) - Add @namespace BlazorBlueprint.Components to BbSortable.razor - Rename T→TItem and SortableItemTemplate→ItemTemplate - Fix ForceFallback bug (param was defined but never passed to JS init()) - Add OnAdd EventCallback + OnAddJS [JSInvokable] for clean cross-list tracking - Add WAI-ARIA: role=list, aria-label, sr-only live region announcements - Add Layout (SortableLayout.List/Grid), Class, AriaLabel, AdditionalAttributes params - Add SortableLayout enum (List, Grid) - Update sortable.js: add onAdd handler, forceFallback now correctly wired from .NET - Create SortableDemo.razor: basic list, handle, multiple lists, grid, kanban - Create CodeExamples/Components/Sortable/{basic,handle,multiple-lists,grid,kanban}.txt - Add Sortable to DemoSidebar.razor navigation (between Spinner and Split Button) - Add Sortable card to Pages/Components/Index.razor - Update API surface baseline to include BbSortable and SortableLayout Co-authored-by: russkyc <32549126+russkyc@users.noreply.github.com> --- .../Components/Sortable/basic.txt | 29 ++ .../CodeExamples/Components/Sortable/grid.txt | 42 ++ .../Components/Sortable/handle.txt | 32 ++ .../Components/Sortable/kanban.txt | 104 ++++ .../Components/Sortable/multiple-lists.txt | 58 +++ .../Pages/Components/Index.razor | 8 + .../Pages/Components/SortableDemo.razor | 464 ++++++++++++++++++ .../Shared/DemoSidebar.razor | 5 + .../Components/Sortable/BbSortable.razor | 102 +--- .../Components/Sortable/BbSortable.razor.cs | 225 +++++++++ .../Components/Sortable/SortableLayout.cs | 19 + .../wwwroot/js/sortable.js | 7 +- ...entsApiSurfaceMatchesBaseline.verified.txt | 23 + 13 files changed, 1027 insertions(+), 91 deletions(-) create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/basic.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/grid.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/handle.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/kanban.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/multiple-lists.txt create mode 100644 demos/BlazorBlueprint.Demo.Shared/Pages/Components/SortableDemo.razor create mode 100644 src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor.cs create mode 100644 src/BlazorBlueprint.Components/Components/Sortable/SortableLayout.cs diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/basic.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/basic.txt new file mode 100644 index 00000000..f80b4ff8 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/basic.txt @@ -0,0 +1,29 @@ + + +
+ + @item +
+
+
+ +@code { + private List _items = + [ + "Design mockups", + "Set up project", + "Implement authentication", + "Write unit tests", + "Deploy to staging", + ]; + + private void HandleUpdate((int OldIndex, int NewIndex) args) + { + var item = _items[args.OldIndex]; + _items.RemoveAt(args.OldIndex); + _items.Insert(args.NewIndex, item); + } +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/grid.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/grid.txt new file mode 100644 index 00000000..09e38f14 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/grid.txt @@ -0,0 +1,42 @@ +@* Layout="SortableLayout.Grid" applies a two-column grid. *@ +@* Override columns via Class, e.g., Class="grid-cols-3 gap-3". *@ + + + + @* Wrap Razor components in a
so Sortable.js tracks a single DOM node. *@ +
+ + +
+ @card.Label +
+
+
+
+ + + +@code { + private record ColorCard(string Label, string Color); + + private List _cards = + [ + new("Ocean", "bg-blue-500"), + new("Forest", "bg-green-500"), + new("Sunset", "bg-orange-500"), + new("Berry", "bg-purple-500"), + new("Rose", "bg-rose-500"), + new("Sky", "bg-cyan-500"), + ]; + + private void HandleUpdate((int OldIndex, int NewIndex) args) + { + var card = _cards[args.OldIndex]; + _cards.RemoveAt(args.OldIndex); + _cards.Insert(args.NewIndex, card); + } +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/handle.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/handle.txt new file mode 100644 index 00000000..5ebbdc9e --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/handle.txt @@ -0,0 +1,32 @@ + + +
+ + + + @item +
+
+
+ +@code { + private List _items = + [ + "Dashboard", + "Analytics", + "Reports", + "Settings", + "Users", + ]; + + private void HandleUpdate((int OldIndex, int NewIndex) args) + { + var item = _items[args.OldIndex]; + _items.RemoveAt(args.OldIndex); + _items.Insert(args.NewIndex, item); + } +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/kanban.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/kanban.txt new file mode 100644 index 00000000..03d17ff6 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/kanban.txt @@ -0,0 +1,104 @@ +@* Kanban board — three connected columns sharing the same Group name. *@ +@* OnRemove fires on the source column; OnAdd fires on the target column. *@ +@* The parent stages the dragged item in _draggedTask between the two callbacks. *@ + +
+ @foreach (var column in _columns) + { +
+
+

@column.Title

+ @column.Items.Count +
+ + + @* Wrap BbCard in a div so Sortable.js tracks a single DOM node. *@ +
+ + + @task.Title + + + + @task.Priority + + + +
+
+
+
+ } +
+ +@code { + private record KanbanTask(string Title, string Priority); + + private class KanbanColumn(string title, List items) + { + public string Title { get; } = title; + public List Items { get; } = items; + } + + private List _columns = + [ + new("To Do", + [ + new("Design system audit", "High"), + new("Update README", "Low"), + new("Fix login bug", "High"), + ]), + new("In Progress", + [ + new("Build sortable demo", "Medium"), + new("Write unit tests", "Medium"), + ]), + new("Done", + [ + new("Project setup", "Low"), + new("CI pipeline", "Medium"), + ]), + ]; + + // Stages the task that is currently being dragged across columns. + // OnRemove (source) populates it; OnAdd (target) consumes it. + private KanbanTask? _draggedTask; + + private void MoveWithin(List list, (int OldIndex, int NewIndex) args) + { + var task = list[args.OldIndex]; + list.RemoveAt(args.OldIndex); + list.Insert(args.NewIndex, task); + } + + private void OnKanbanRemove(KanbanColumn source, (int OldIndex, int NewIndex) args) + { + _draggedTask = source.Items[args.OldIndex]; + source.Items.RemoveAt(args.OldIndex); + } + + private void OnKanbanAdd(KanbanColumn target, (int OldIndex, int NewIndex) args) + { + if (_draggedTask is null) + { + return; + } + + target.Items.Insert(Math.Min(args.NewIndex, target.Items.Count), _draggedTask); + _draggedTask = null; + } + + private static BadgeVariant GetPriorityVariant(string priority) => priority switch + { + "High" => BadgeVariant.Destructive, + "Medium" => BadgeVariant.Default, + _ => BadgeVariant.Secondary, + }; +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/multiple-lists.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/multiple-lists.txt new file mode 100644 index 00000000..12848318 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Sortable/multiple-lists.txt @@ -0,0 +1,58 @@ +@* Items can be dragged between the two lists. Both lists share the same Group name. *@ + +
+
+

Available

+ + +
+ + @item +
+
+
+
+ +
+

Selected

+ + +
+ + @item +
+
+
+
+
+ +@code { + private List _available = ["React", "Vue", "Angular", "Svelte", "Solid"]; + private List _selected = ["Blazor"]; + + private static void MoveWithin(List list, (int OldIndex, int NewIndex) args) + { + var item = list[args.OldIndex]; + list.RemoveAt(args.OldIndex); + list.Insert(args.NewIndex, item); + } + + private static void MoveAcross(List source, List target, (int OldIndex, int NewIndex) args) + { + var item = source[args.OldIndex]; + source.RemoveAt(args.OldIndex); + target.Insert(args.NewIndex, item); + } +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/Index.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/Index.razor index aa673e6f..c294c720 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/Index.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/Index.razor @@ -659,6 +659,14 @@

+ + +

Sortable

+

+ Drag-and-drop sortable lists, grids, and Kanban boards +

+
+

Skeleton

diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SortableDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SortableDemo.razor new file mode 100644 index 00000000..e633c987 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SortableDemo.razor @@ -0,0 +1,464 @@ +@page "/components/sortable" + +Sortable Component - Blazor Blueprint + +
+
+
+

Sortable Component

+

+ Drag-and-drop sortable lists and grids powered by Sortable.js. + Supports single lists, connected multi-lists, grid layouts, and Kanban boards. +

+
+
+ + +
+
+

Basic List

+

+ Drag any item to reorder it. The + OnUpdate callback provides + the old and new index so you can update your data model. +

+
+ + + +
+ + @item +
+
+
+ + +
+ + +
+
+

With Drag Handle

+

+ Use the Handle parameter to + restrict dragging to a specific child element. Only that element initiates the drag; + the rest of the item remains interactive. +

+
+ + + +
+ + + + @item +
+
+
+ + +
+ + +
+
+

Multiple Connected Lists

+

+ Give multiple BbSortable + instances the same Group name to allow items to move between them. + For two-list transfers, OnRemove + alone is sufficient — the callback provides the landing index in the target so you can + insert straight into the other list. +

+
+ +
+
+

Available

+ + +
+ + @item +
+
+
+
+ +
+

Selected

+ + +
+ + @item +
+
+
+
+
+ + +
+ + +
+
+

Grid Layout

+

+ Set Layout="SortableLayout.Grid" + for a two-column grid. Pass a Class + like grid-cols-3 gap-3 to override + the column count. +

+
+ +
+ + + When using Razor components inside ItemTemplate, + wrap them in a plain <div> so Sortable.js + can track each item as a single DOM node. + +
+ + + + @* Wrap BbCard in a
— Sortable.js needs a single DOM root per item. *@ +
+ + +
+ @card.Label +
+
+
+
+ + + + +
+ + +
+
+

Kanban Board

+

+ Three connected columns each backed by their own + BbSortable. + OnRemove fires on the source column + and removes the task; OnAdd fires + on the target column and inserts it. The parent stages the in-flight task in a + _draggedTask field between the + two callbacks. +

+
+ +
+ + + When using Razor components inside ItemTemplate, + wrap them in a plain <div> so Sortable.js + can track each item as a single DOM node. + +
+ +
+ @foreach (var column in _kanbanColumns) + { +
+
+

@column.Title

+ @column.Items.Count +
+ + + @* Wrap BbCard in a
— Sortable.js needs a single DOM root per item. *@ +
+ + + @task.Title + + + + @task.Priority + + + +
+ + +
+ } +
+ + +
+ + + + + + The container has role="list" + and accepts an accessible label via the + AriaLabel parameter. + + + A visually-hidden role="status" + live region announces each move to screen readers + (e.g., "Item moved from position 2 to position 4."). + + + Add role="listitem" to the root + element of each ItemTemplate + to complete the list landmark semantics for screen readers. + + + + + Mouse / Touch + + + + + +
+
+

API Reference

+

Component properties and parameters.

+
+ + + + Render fragment used to display each item. The context variable + provides the data item. When the root element is a Razor component, wrap it in + a <div> so Sortable.js can track it as a single DOM node. + + + The list of items to render and reorder. + + + Raised when an item is reordered within this list. Remove from + OldIndex and insert at NewIndex. + + + Raised when an item is moved out of this list into a connected list. + OldIndex is the item's position in this list; + NewIndex is its landing position in the target list. + + + Raised when an item from a connected list is dropped into this list. + OldIndex is its position in the source list; + NewIndex is its landing position in this list. + Use together with OnRemove on the source to stage the item via a + shared field and then insert it here. + + + Sortable.js group name. Lists sharing the same name can exchange items. + Defaults to a unique GUID (isolated list). + + + Pull behaviour: "clone" copies items; null moves them. + + + Whether items from connected lists can be dropped into this one. + + + Whether items within this list can be reordered. + + + CSS selector for the drag-handle element. Only that element initiates a drag. + + + CSS selector for child elements that cannot be dragged. + + + Use the software fallback renderer instead of native HTML5 drag-and-drop. + Recommended for consistent cross-browser behaviour. + + + Visual layout: List (vertical column) or Grid + (two-column grid). Override columns via Class. + + + HTML element id for the sortable container. Auto-generated by default. + + + Accessible label exposed as aria-label on the container. + + + Additional CSS classes for the container element. + + + +
+

SortableLayout

+
+

List — items stacked vertically in a single column (default).

+

+ Grid — items in a two-column grid. Pass a custom + Class such as + grid-cols-3 gap-4 + to change the column count. +

+
+
+
+
+ +@code { + // ── Shared helpers ──────────────────────────────────────────────────────── + + private static void MoveWithin(List list, (int OldIndex, int NewIndex) args) + { + var item = list[args.OldIndex]; + list.RemoveAt(args.OldIndex); + list.Insert(args.NewIndex, item); + } + + private static void MoveAcross(List source, List target, (int OldIndex, int NewIndex) args) + { + var item = source[args.OldIndex]; + source.RemoveAt(args.OldIndex); + target.Insert(args.NewIndex, item); + } + + // ── Basic list ──────────────────────────────────────────────────────────── + + private List _basicItems = + [ + "Design mockups", + "Set up project", + "Implement authentication", + "Write unit tests", + "Deploy to staging", + ]; + + // ── Handle list ─────────────────────────────────────────────────────────── + + private List _handleItems = + [ + "Dashboard", + "Analytics", + "Reports", + "Settings", + "Users", + ]; + + // ── Multiple lists ──────────────────────────────────────────────────────── + + private readonly string _transferGroup = Guid.NewGuid().ToString(); + private List _available = ["React", "Vue", "Angular", "Svelte", "Solid"]; + private List _selected = ["Blazor"]; + + // ── Grid ────────────────────────────────────────────────────────────────── + + private record ColorCard(string Label, string Color); + + private List _gridCards = + [ + new("Ocean", "bg-blue-500"), + new("Forest", "bg-green-500"), + new("Sunset", "bg-orange-500"), + new("Berry", "bg-purple-500"), + new("Rose", "bg-rose-500"), + new("Sky", "bg-cyan-500"), + ]; + + // ── Kanban ──────────────────────────────────────────────────────────────── + + private record KanbanTask(string Title, string Priority); + + private sealed class KanbanColumn(string title, List items) + { + public string Title { get; } = title; + public List Items { get; } = items; + } + + private readonly string _kanbanGroup = Guid.NewGuid().ToString(); + + private List _kanbanColumns = + [ + new("To Do", + [ + new("Design system audit", "High"), + new("Update README", "Low"), + new("Fix login bug", "High"), + ]), + new("In Progress", + [ + new("Build sortable demo", "Medium"), + new("Write unit tests", "Medium"), + ]), + new("Done", + [ + new("Project setup", "Low"), + new("CI pipeline", "Medium"), + ]), + ]; + + // Stages the task that is in-flight between columns. + // KanbanOnRemove (source) populates it; KanbanOnAdd (target) consumes it. + private KanbanTask? _draggedTask; + + private void KanbanOnRemove(KanbanColumn source, (int OldIndex, int NewIndex) args) + { + _draggedTask = source.Items[args.OldIndex]; + source.Items.RemoveAt(args.OldIndex); + } + + private void KanbanOnAdd(KanbanColumn target, (int OldIndex, int NewIndex) args) + { + if (_draggedTask is null) + { + return; + } + + target.Items.Insert(Math.Min(args.NewIndex, target.Items.Count), _draggedTask); + _draggedTask = null; + } + + private static BadgeVariant GetPriorityVariant(string priority) => priority switch + { + "High" => BadgeVariant.Destructive, + "Medium" => BadgeVariant.Default, + _ => BadgeVariant.Secondary, + }; +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor index 83c634e1..dcef679e 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Shared/DemoSidebar.razor @@ -557,6 +557,11 @@ Spinner + + + Sortable + + Split Button diff --git a/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor b/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor index 33a4e8c7..9ea7f2b9 100644 --- a/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor +++ b/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor @@ -1,96 +1,18 @@ -@using System.Diagnostics.CodeAnalysis - -@implements IAsyncDisposable - -@inject IJSRuntime JsRuntime - -@typeparam T - -
- @foreach (var item in Items) +@namespace BlazorBlueprint.Components +@typeparam TItem + +
+ @foreach (var item in Items ?? []) { - @if (SortableItemTemplate is not null) + @if (ItemTemplate is not null) { - @SortableItemTemplate(item) + @ItemTemplate(item) } }
-@code { - - private IJSObjectReference? _module; - - [Parameter] - public RenderFragment? SortableItemTemplate { get; set; } - - [Parameter, AllowNull] - public List Items { get; set; } - - [Parameter] - public EventCallback<(int oldIndex, int newIndex)> OnUpdate { get; set; } - - [Parameter] - public EventCallback<(int oldIndex, int newIndex)> OnRemove { get; set; } - - [Parameter] - public string Id { get; set; } = Guid.NewGuid().ToString(); - - [Parameter] - public string Group { get; set; } = Guid.NewGuid().ToString(); - - [Parameter] - public string? Pull { get; set; } - - [Parameter] - public bool Put { get; set; } = true; - - [Parameter] - public bool Sort { get; set; } = true; - - [Parameter] - public string Handle { get; set; } = string.Empty; - - [Parameter] - public string? Filter { get; set; } - - [Parameter] - public bool ForceFallback { get; set; } = true; - - private DotNetObjectReference>? _ref; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - _ref = DotNetObjectReference.Create(this); - _module = await JsRuntime.InvokeAsync("import", "./_content/BlazorBlueprint.Components/js/sortable.js"); - await _module.InvokeAsync("init", Id, Group, Pull, Put, Sort, Handle, Filter, _ref); - } - } - - [JSInvokable] - public void OnUpdateJS(int oldIndex, int newIndex) - { - // invoke the OnUpdate event passing in the oldIndex and the newIndex - OnUpdate.InvokeAsync((oldIndex, newIndex)); - } - - [JSInvokable] - public void OnRemoveJS(int oldIndex, int newIndex) - { - // remove the item from the list - OnRemove.InvokeAsync((oldIndex, newIndex)); - } - - public void Dispose() => _ref?.Dispose(); - - public async ValueTask DisposeAsync() - { - _ref?.Dispose(); - if (_module != null) - { - await _module.DisposeAsync(); - } - } - -} +
@_liveAnnouncement
diff --git a/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor.cs b/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor.cs new file mode 100644 index 00000000..031cf375 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Sortable/BbSortable.razor.cs @@ -0,0 +1,225 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace BlazorBlueprint.Components; + +/// +/// A drag-and-drop sortable list or grid component powered by Sortable.js. +/// +/// +/// +/// Supports single lists, connected multi-lists (items move between lists), and grid +/// layouts. Drag-and-drop is handled by Sortable.js; state is managed in .NET via the +/// and callbacks. +/// +/// +/// When using a Razor component as the root element of , wrap +/// it in a plain <div> so that Sortable.js can track it as a single DOM node. +/// +/// +/// The type of items in the sortable list. +public partial class BbSortable : ComponentBase, IAsyncDisposable +{ + private IJSObjectReference? _module; + private DotNetObjectReference>? _ref; + private string _liveAnnouncement = string.Empty; + + [Inject] + private IJSRuntime JsRuntime { get; set; } = null!; + + /// + /// Gets or sets the render fragment used to display each item. + /// The context parameter provides the data item of type . + /// + /// + /// When using a Razor component as the root element, wrap it in a plain + /// <div> so that Sortable.js can track the item as a single DOM node. + /// + [Parameter] + public RenderFragment? ItemTemplate { get; set; } + + /// + /// Gets or sets the list of items to render and sort. + /// + [Parameter, AllowNull] + public List Items { get; set; } + + /// + /// Raised when an item is reordered within this list. + /// The tuple provides the original index and the new index after the move. + /// + [Parameter] + public EventCallback<(int OldIndex, int NewIndex)> OnUpdate { get; set; } + + /// + /// Raised when an item is dragged out of this list into another connected list. + /// The tuple provides the item's original index in this list and its landing index + /// in the target list. + /// + [Parameter] + public EventCallback<(int OldIndex, int NewIndex)> OnRemove { get; set; } + + /// + /// Raised when an item from a connected list is dropped into this list. + /// The tuple provides the item's original index in the source list and its + /// landing index in this list. + /// + /// + /// Use alongside on the source list to coordinate cross-list + /// moves: in the handler remove the item from the source and + /// store it; in the handler insert the stored item into this list. + /// + [Parameter] + public EventCallback<(int OldIndex, int NewIndex)> OnAdd { get; set; } + + /// + /// Gets or sets the HTML element id for the sortable container. + /// Defaults to a new GUID. + /// + [Parameter] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the Sortable.js group name used to connect multiple lists. + /// Lists sharing the same group name can exchange items. + /// Defaults to a new GUID (isolated list). + /// + [Parameter] + public string Group { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the pull behaviour for the group. + /// Use "clone" to copy items instead of moving them, + /// or null (default) to move them. + /// + [Parameter] + public string? Pull { get; set; } + + /// + /// Gets or sets whether items from connected lists can be dropped into this one. + /// Defaults to true. + /// + [Parameter] + public bool Put { get; set; } = true; + + /// + /// Gets or sets whether items within this list can be reordered. + /// Set to false to create a list that only accepts items from other lists. + /// Defaults to true. + /// + [Parameter] + public bool Sort { get; set; } = true; + + /// + /// Gets or sets a CSS selector used as the drag-handle element inside each item. + /// When set, only the matched child element initiates a drag. + /// + [Parameter] + public string Handle { get; set; } = string.Empty; + + /// + /// Gets or sets a CSS selector for child elements that cannot be dragged. + /// + [Parameter] + public string? Filter { get; set; } + + /// + /// Gets or sets whether to use a software fallback renderer instead of the + /// browser's native HTML5 drag-and-drop API. + /// Defaults to true for consistent cross-browser behaviour. + /// + [Parameter] + public bool ForceFallback { get; set; } = true; + + /// + /// Gets or sets the visual layout of the sortable container. + /// + [Parameter] + public SortableLayout Layout { get; set; } = SortableLayout.List; + + /// + /// Gets or sets additional CSS classes to apply to the sortable container. + /// + [Parameter] + public string? Class { get; set; } + + /// + /// Gets or sets an accessible label for the sortable container, + /// exposed as aria-label on the container element. + /// + [Parameter] + public string? AriaLabel { get; set; } + + /// + /// Gets or sets any additional HTML attributes to apply to the container element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string CssClass => ClassNames.cn( + Layout switch + { + SortableLayout.Grid => "grid grid-cols-2 gap-2", + _ => "flex flex-col gap-2" + }, + Class + ); + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _ref = DotNetObjectReference.Create(this); + _module = await JsRuntime.InvokeAsync( + "import", "./_content/BlazorBlueprint.Components/js/sortable.js"); + await _module.InvokeAsync( + "init", Id, Group, Pull, Put, Sort, Handle, Filter, _ref, ForceFallback); + } + } + + /// + /// Invoked from JavaScript when an item is reordered within this list. + /// + [JSInvokable] + public async Task OnUpdateJS(int oldIndex, int newIndex) + { + _liveAnnouncement = $"Item moved from position {oldIndex + 1} to position {newIndex + 1}."; + await OnUpdate.InvokeAsync((oldIndex, newIndex)); + StateHasChanged(); + } + + /// + /// Invoked from JavaScript when an item is moved out of this list into another. + /// + [JSInvokable] + public async Task OnRemoveJS(int oldIndex, int newIndex) + { + _liveAnnouncement = $"Item removed from position {oldIndex + 1} and placed at position {newIndex + 1} in another list."; + await OnRemove.InvokeAsync((oldIndex, newIndex)); + StateHasChanged(); + } + + /// + /// Invoked from JavaScript when an item from a connected list is dropped into this list. + /// + [JSInvokable] + public async Task OnAddJS(int oldIndex, int newIndex) + { + _liveAnnouncement = $"Item received at position {newIndex + 1}."; + await OnAdd.InvokeAsync((oldIndex, newIndex)); + StateHasChanged(); + } + + /// + public async ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + _ref?.Dispose(); + if (_module is not null) + { + await _module.DisposeAsync(); + } + } +} diff --git a/src/BlazorBlueprint.Components/Components/Sortable/SortableLayout.cs b/src/BlazorBlueprint.Components/Components/Sortable/SortableLayout.cs new file mode 100644 index 00000000..58f94615 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Sortable/SortableLayout.cs @@ -0,0 +1,19 @@ +namespace BlazorBlueprint.Components; + +/// +/// Defines the visual layout mode for the container. +/// +public enum SortableLayout +{ + /// + /// Items are stacked vertically in a single column (default). + /// + List, + + /// + /// Items are arranged in a two-column grid. + /// Override the column count by supplying a Tailwind grid-cols-* class + /// via the Class parameter (e.g., Class="grid-cols-3 gap-4"). + /// + Grid +} diff --git a/src/BlazorBlueprint.Components/wwwroot/js/sortable.js b/src/BlazorBlueprint.Components/wwwroot/js/sortable.js index fad8a90b..5eed2f55 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/sortable.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/sortable.js @@ -1,4 +1,4 @@ -export function init(id, group, pull, put, sort, handle, filter, component, forceFallback) { +export function init(id, group, pull, put, sort, handle, filter, component, forceFallback) { var sortable = new Sortable(document.getElementById(id), { animation: 200, group: { @@ -29,6 +29,11 @@ // Notify .NET to update its model and re-render component.invokeMethodAsync('OnRemoveJS', event.oldDraggableIndex, event.newDraggableIndex); + }, + onAdd: (event) => { + // The source list's onRemove handler already reverted the DOM. + // Notify the target list so its .NET model can be updated. + component.invokeMethodAsync('OnAddJS', event.oldDraggableIndex, event.newDraggableIndex); } }); } diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 24ab396c..bf651a13 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -2481,6 +2481,25 @@ - Value : Double - ValueChanged : EventCallback +### BbSortable`1 (BlazorBlueprint.Components) + - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] + - AriaLabel : String + - Class : String + - Filter : String + - ForceFallback : Boolean + - Group : String + - Handle : String + - Id : String + - ItemTemplate : RenderFragment + - Items : List + - Layout : SortableLayout + - OnAdd : EventCallback> + - OnRemove : EventCallback> + - OnUpdate : EventCallback> + - Pull : String + - Put : Boolean + - Sort : Boolean + ### BbSpinner (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] - AriaLabel : String @@ -3339,6 +3358,10 @@ - Horizontal = 0 - Vertical = 1 +### SortableLayout (BlazorBlueprint.Components) + - List = 0 + - Grid = 1 + ### SpinnerSize (BlazorBlueprint.Components) - Small = 0 - Default = 1 From 1edd4fbecaf7a472c7b044c4bb60f6ea7ebbb219 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:13:25 +0000 Subject: [PATCH 4/5] fix(sortable): correct aria-label typo in SortableDemo grid section Co-authored-by: russkyc <32549126+russkyc@users.noreply.github.com> --- .../Pages/Components/SortableDemo.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SortableDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SortableDemo.razor index e633c987..162d94b2 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SortableDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/SortableDemo.razor @@ -150,7 +150,7 @@ @* Wrap BbCard in a
— Sortable.js needs a single DOM root per item. *@ From f7205cc26ecbc14fade99417c29b9bed503f9a7a Mon Sep 17 00:00:00 2001 From: Russell Camo Date: Thu, 5 Mar 2026 23:17:11 +0800 Subject: [PATCH 5/5] feat(Sortable): implement lazy loading for Sortable library --- .../wwwroot/js/sortable.js | 49 ++++++++++++++++++- .../wwwroot/lib/sortable/sortable.min.js | 16 +++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/BlazorBlueprint.Components/wwwroot/js/sortable.js b/src/BlazorBlueprint.Components/wwwroot/js/sortable.js index 5eed2f55..9659cb1f 100644 --- a/src/BlazorBlueprint.Components/wwwroot/js/sortable.js +++ b/src/BlazorBlueprint.Components/wwwroot/js/sortable.js @@ -1,4 +1,51 @@ -export function init(id, group, pull, put, sort, handle, filter, component, forceFallback) { +/** @type {Promise|null} */ +let sortableLoadPromise = null; + +/** @type {any} */ +let sortableLib = null; + +/** + * Lazily load the Sortable ESM library. + * Uses a single-flight pattern to prevent duplicate loads. + * @returns {Promise} + */ +async function loadSortable() { + if (sortableLib) return sortableLib; + + // Check for globally loaded Sortable first (e.g., via