From 177d87ea0c19dfd112c5fb69a8e544de5ed81dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nykl=C3=AD=C4=8Dek?= <60318239+ONyklicek@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:11:39 +0200 Subject: [PATCH 01/30] Stop a focused input from silencing the morph for the whole table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table with reorderable() or columnReorderable() stopped responding to its own search box: the server filtered correctly and sent the rows back, and the client threw the response away. No error, nothing in the console. The drag controller's two morph hooks asked "is any input inside the table focused?" — of every node from the sortable wrapper down. skip() takes the whole subtree with it and contains() is inclusive, so the answer came back yes at the wrapper itself and the morph never entered the table. The search box is an input inside the table, which made it the one control guaranteed to silence the render it had just asked for. Both guards now name what they protect: the cell being edited, found by the [data-record-key][data-column-name] pair the editable columns render — the selector wireTableLive already reads in busy() — and skipped only when the morph is at that exact node. The drag guard is unchanged; it has moved rows the server knows nothing about. Two things found on the way, in the same hooks: - morph.updated fires per patched node, so every morph tore down and rebuilt both SortableJS instances a hundred times over. Coalesced to one setup(). - The hooks were registered from init(), and Livewire.hook() has no off switch, so every re-init stacked another pair — a second table, a wire:navigate, a table in a lazily loaded modal — each copy still answering for a component that no longer existed. Installed once per document now, with live controllers in a map keyed by their wrapper element. Keyed by the element because Alpine calls destroy() with a merge proxy of the scope, not the instance init() saw, so removing by identity removes nothing. verify-sortable-morph.mjs (25/25) drives all of it against a new /previews/sortable-morph, and fails on the old bundle exactly as reported: 6 rows in, 6 rows out, the morph stopping one node inside the wrapper. Pest covers the guard's shape, the dist not drifting from source, and the cross-package contract that an editable cell really renders that attribute pair — it lives in wire-table and could otherwise rename itself out from under the selector. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 + architecture/sortable.md | 49 ++++ packages/sortable/dist/wire-sortable.js | 2 +- packages/sortable/resources/js/sortable.js | 152 ++++++++--- .../sortable/tests/Feature/MorphGuardTest.php | 201 ++++++++++++++ .../app/Livewire/Previews/SortablePreview.php | 35 ++- workbench/routes/web.php | 1 + workbench/scripts/verify-sortable-morph.mjs | 249 ++++++++++++++++++ 8 files changed, 662 insertions(+), 32 deletions(-) create mode 100644 packages/sortable/tests/Feature/MorphGuardTest.php create mode 100644 workbench/scripts/verify-sortable-morph.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c638b3..c9f122bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to the Wire ecosystem will be documented in this file. +## [1.16.1] + +### Fixed +- **A reorderable table stopped responding to its own search box.** With `reorderable()` or `columnReorderable()` on, typing in the search field did nothing: the server filtered correctly and sent the rows back, and the client threw the whole response away — no error, nothing in the console, the same row count as before. The drag controller registers two Livewire morph hooks so a drag in progress, and a cell being typed into, survive a re-render, and both asked the wrong question: *is any input inside the table focused?* — of every node from the sortable wrapper down. `skip()` takes the whole subtree with it and `contains()` is inclusive, so the answer came back yes at the wrapper itself and the morph never entered the table. The search box is an input inside the table, which made it the one control guaranteed to silence the render it had just asked for; a filter input or the per-page select did the same. Both guards now name what they protect: the cell being edited, identified by the `[data-record-key][data-column-name]` pair the editable columns render — the selector `wireTableLive` already reads in `busy()` — and skipped only when the morph is at that exact node, so its siblings and the rest of the table reconcile normally. The drag guard is unchanged: a drag still stops the morph outright, because it has moved rows the server render knows nothing about. While at it, the post-morph re-init is queued once per morph instead of once per patched node — `morph.updated` fires for every element Livewire touches, so a table of any size was tearing down and rebuilding both SortableJS instances a hundred times a render. Both hooks are now also installed **once per document** instead of once per controller: `Livewire.hook()` has no off switch, so a pair registered from `init()` stacked another pair on every re-init — a second reorderable table, a `wire:navigate`, a table inside a lazily loaded modal — and every stacked copy went on answering for a component that no longer existed, a destroyed controller's `isDragging` still able to block every morph on the page. The live controllers sit in a module-level map keyed by their wrapper element, which is what `wire-table`'s record-actions guard and the fill handle already do; keyed by the element because Alpine calls `destroy()` with a merge proxy of the scope rather than the instance `init()` saw, so removing by identity removes nothing. Browser-verified by `workbench/scripts/verify-sortable-morph.mjs` (25/25) against a new `/previews/sortable-morph`, which fails on the old bundle exactly as reported: 6 rows in, 6 rows out, the morph stopping one node inside the wrapper. + ## [1.16.0] ### Added diff --git a/architecture/sortable.md b/architecture/sortable.md index dd21eabe..03bd2fca 100644 --- a/architecture/sortable.md +++ b/architecture/sortable.md @@ -62,6 +62,48 @@ Persistence for saved column order preferences. Sortable UI fragments and scripts. +### `resources/js/sortable.js` — the drag controller and its morph guards + +One Alpine component wraps the whole table whenever `reorderable()` **or** +`columnReorderable()` is on, and it registers two global Livewire morph hooks. +They are the highest-blast-radius code in the package: a morph hook that says +"skip" decides whether a Livewire response is applied at all, for the entire +table, whatever the render was about. + +Two rules when touching them: + +1. **`skip()` takes the whole subtree of `el` with it, and `contains()` is + inclusive.** A guard evaluated at the wrapper therefore skips the table. Any + condition must name the exact node it protects (`el === cell`), never "the + focused element is somewhere below this one". +2. **"An input inside the table" is not the same as "a cell being edited".** The + search box, the filter inputs and the per-page select are inputs inside the + table, and each of them exists to *cause* the morph. The cell being edited is + identified by `[data-record-key][data-column-name]` — the same pair + `wireTableLive.busy()` reads, and a cross-package contract asserted by + `packages/sortable/tests/Feature/MorphGuardTest.php`. + +3. **The hooks are installed once per document, not once per controller.** + `Livewire.hook()` has no off switch, so registering from `init()` stacks a + fresh pair on every re-init — a second reorderable table, a `wire:navigate`, + a table in a lazily loaded modal. Live controllers live in a module-level + `Map` keyed by their wrapper element, added in `init()` and removed in + `destroy()`. Keyed by the element deliberately: Alpine calls `destroy()` with + a merge proxy of the scope, **not** the instance `init()` saw, so + `delete(this)` silently deletes nothing — and an element key also lets a + replacement take over the entry rather than join it. Same shape as + `packages/table/resources/js/record-actions.js` and + `packages/core/resources/js/fill/controller.js`. + +The drag guard is the one case that legitimately skips everything: mid-drag the +DOM holds rows the server render knows nothing about. + +`morph.updated` fires once per patched element, so the re-init it schedules is +coalesced (`scheduleSetup()`) — one `setup()` per morph, not one per node. + +The bundle is committed: any change here needs `npm run build:sortable-assets`, +which `SortableAssetTest` and `MorphGuardTest` will fail without. + ## Typical Changes - sortable feature wiring: @@ -89,6 +131,13 @@ Add integration tests if plugin boot or state flow changed: - `vendor/bin/pest --configuration phpunit.xml --testsuite "Integration"` +Anything in `resources/js/sortable.js` is browser-only and Pest cannot see it — +rebuild the bundle and run the drivers: + +- `npm run build:sortable-assets` +- `npm run verify:drivers -- sortable-morph` — the morph guards +- `npm run verify:drivers -- column-reorder` — the header drag and the body mirror + Useful authored docs: - `docs/sortable/overview.md` diff --git a/packages/sortable/dist/wire-sortable.js b/packages/sortable/dist/wire-sortable.js index 1eb1bd2b..21747644 100644 --- a/packages/sortable/dist/wire-sortable.js +++ b/packages/sortable/dist/wire-sortable.js @@ -1 +1 @@ -(()=>{function Fe(o,t,e){return(t=Xe(t))in o?Object.defineProperty(o,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):o[t]=e,o}function z(){return z=Object.assign?Object.assign.bind():function(o){for(var t=1;t"&&(t=t.substring(1)),o)try{if(o.matches)return o.matches(t);if(o.msMatchesSelector)return o.msMatchesSelector(t);if(o.webkitMatchesSelector)return o.webkitMatchesSelector(t)}catch{return!1}return!1}}function ye(o){return o.host&&o!==document&&o.host.nodeType&&o.host!==o?o.host:o.parentNode}function B(o,t,e,n){if(o){e=e||document;do{if(t!=null&&(t[0]===">"?o.parentNode===e&&Ht(o,t):Ht(o,t))||n&&o===e)return o;if(o===e)break}while(o=ye(o))}return null}var fe=/\s+/g;function k(o,t,e){if(o&&t)if(o.classList)o.classList[e?"add":"remove"](t);else{var n=(" "+o.className+" ").replace(fe," ").replace(" "+t+" "," ");o.className=(n+(e?" "+t:"")).replace(fe," ")}}function h(o,t,e){var n=o&&o.style;if(n){if(e===void 0)return document.defaultView&&document.defaultView.getComputedStyle?e=document.defaultView.getComputedStyle(o,""):o.currentStyle&&(e=o.currentStyle),t===void 0?e:e[t];!(t in n)&&t.indexOf("webkit")===-1&&(t="-webkit-"+t),n[t]=e+(typeof e=="string"?"":"px")}}function ft(o,t){var e="";if(typeof o=="string")e=o;else do{var n=h(o,"transform");n&&n!=="none"&&(e=n+" "+e)}while(!t&&(o=o.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(e)}function Ee(o,t,e){if(o){var n=o.getElementsByTagName(t),i=0,r=n.length;if(e)for(;i=r:a=i<=r,!a)return n;if(n===q())break;n=tt(n,!1)}return!1}function dt(o,t,e,n){for(var i=0,r=0,a=o.children;r2&&arguments[2]!==void 0?arguments[2]:{},i=n.evt,r=He(n,Ue);Tt.pluginEvent.bind(p)(t,e,G({dragEl:c,parentEl:_,ghostEl:g,rootEl:S,nextEl:at,lastDownEl:Mt,cloneEl:D,cloneHidden:J,dragStarted:bt,putSortable:T,activeSortable:p.active,originalEvent:i,oldIndex:ct,oldDraggableIndex:Dt,newIndex:F,newDraggableIndex:Q,hideGhostForTarget:Oe,unhideGhostForTarget:xe,cloneNowHidden:function(){J=!0},cloneNowShown:function(){J=!1},dispatchSortableEvent:function(l){x({sortable:e,name:l,originalEvent:i})}},r))};function x(o){ze(G({putSortable:T,cloneEl:D,targetEl:c,rootEl:S,oldIndex:ct,oldDraggableIndex:Dt,newIndex:F,newDraggableIndex:Q},o))}var c,_,g,S,at,Mt,D,J,ct,F,Dt,Q,Ot,T,ut=!1,Wt=!1,Lt=[],it,X,jt,$t,pe,ge,bt,st,_t,Ct=!1,xt=!1,Rt,I,Kt=[],Jt=!1,Xt=[],Yt=typeof document<"u",Nt=oe,me=At||U?"cssFloat":"float",Ve=Yt&&!be&&!oe&&"draggable"in document.createElement("div"),Ae=(function(){if(Yt){if(U)return!1;var o=document.createElement("x");return o.style.cssText="pointer-events:auto",o.style.pointerEvents==="auto"}})(),Te=function(t,e){var n=h(t),i=parseInt(n.width)-parseInt(n.paddingLeft)-parseInt(n.paddingRight)-parseInt(n.borderLeftWidth)-parseInt(n.borderRightWidth),r=dt(t,0,e),a=dt(t,1,e),l=r&&h(r),s=a&&h(a),u=l&&parseInt(l.marginLeft)+parseInt(l.marginRight)+A(r).width,d=s&&parseInt(s.marginLeft)+parseInt(s.marginRight)+A(a).width;if(n.display==="flex")return n.flexDirection==="column"||n.flexDirection==="column-reverse"?"vertical":"horizontal";if(n.display==="grid")return n.gridTemplateColumns.split(" ").length<=1?"vertical":"horizontal";if(r&&l.float&&l.float!=="none"){var f=l.float==="left"?"left":"right";return a&&(s.clear==="both"||s.clear===f)?"vertical":"horizontal"}return r&&(l.display==="block"||l.display==="flex"||l.display==="table"||l.display==="grid"||u>=i&&n[me]==="none"||a&&n[me]==="none"&&u+d>i)?"vertical":"horizontal"},Ze=function(t,e,n){var i=n?t.left:t.top,r=n?t.right:t.bottom,a=n?t.width:t.height,l=n?e.left:e.top,s=n?e.right:e.bottom,u=n?e.width:e.height;return i===l||r===s||i+a/2===l+u/2},Qe=function(t,e){var n;return Lt.some(function(i){var r=i[P].options.emptyInsertThreshold;if(!(!r||ie(i))){var a=A(i),l=t>=a.left-r&&t<=a.right+r,s=e>=a.top-r&&e<=a.bottom+r;if(l&&s)return n=i}}),n},Ie=function(t){function e(r,a){return function(l,s,u,d){var f=l.options.group.name&&s.options.group.name&&l.options.group.name===s.options.group.name;if(r==null&&(a||f))return!0;if(r==null||r===!1)return!1;if(a&&r==="clone")return r;if(typeof r=="function")return e(r(l,s,u,d),a)(l,s,u,d);var b=(a?l:s).options.group.name;return r===!0||typeof r=="string"&&r===b||r.join&&r.indexOf(b)>-1}}var n={},i=t.group;(!i||Qt(i)!="object")&&(i={name:i}),n.name=i.name,n.checkPull=e(i.pull,!0),n.checkPut=e(i.put),n.revertClone=i.revertClone,t.group=n},Oe=function(){!Ae&&g&&h(g,"display","none")},xe=function(){!Ae&&g&&h(g,"display","")};Yt&&!be&&document.addEventListener("click",function(o){if(Wt)return o.preventDefault(),o.stopPropagation&&o.stopPropagation(),o.stopImmediatePropagation&&o.stopImmediatePropagation(),Wt=!1,!1},!0);var rt=function(t){if(c){t=t.touches?t.touches[0]:t;var e=Qe(t.clientX,t.clientY);if(e){var n={};for(var i in t)t.hasOwnProperty(i)&&(n[i]=t[i]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[P]._onDragOver(n)}}},Je=function(t){c&&c.parentNode[P]._isOutsideThisEl(t.target)};function p(o,t){if(!(o&&o.nodeType&&o.nodeType===1))throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(o));this.el=o,this.options=t=z({},t),o[P]=this;var e={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(o.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Te(o,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(a,l){a.setData("Text",l.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:p.supportPointer!==!1&&"PointerEvent"in window&&(!Et||oe),emptyInsertThreshold:5};Tt.initializePlugins(this,o,e);for(var n in e)!(n in t)&&(t[n]=e[n]);Ie(t);for(var i in this)i.charAt(0)==="_"&&typeof this[i]=="function"&&(this[i]=this[i].bind(this));this.nativeDraggable=t.forceFallback?!1:Ve,this.nativeDraggable&&(this.options.touchStartThreshold=1),t.supportPointer?v(o,"pointerdown",this._onTapStart):(v(o,"mousedown",this._onTapStart),v(o,"touchstart",this._onTapStart)),this.nativeDraggable&&(v(o,"dragover",this),v(o,"dragenter",this)),Lt.push(this.el),t.store&&t.store.get&&this.sort(t.store.get(this)||[]),z(this,je())}p.prototype={constructor:p,_isOutsideThisEl:function(t){!this.el.contains(t)&&t!==this.el&&(st=null)},_getDirection:function(t,e){return typeof this.options.direction=="function"?this.options.direction.call(this,t,e,c):this.options.direction},_onTapStart:function(t){if(t.cancelable){var e=this,n=this.el,i=this.options,r=i.preventOnFilter,a=t.type,l=t.touches&&t.touches[0]||t.pointerType&&t.pointerType==="touch"&&t,s=(l||t).target,u=t.target.shadowRoot&&(t.path&&t.path[0]||t.composedPath&&t.composedPath()[0])||s,d=i.filter;if(sn(n),!c&&!(/mousedown|pointerdown/.test(a)&&t.button!==0||i.disabled)&&!u.isContentEditable&&!(!this.nativeDraggable&&Et&&s&&s.tagName.toUpperCase()==="SELECT")&&(s=B(s,i.draggable,n,!1),!(s&&s.animated)&&Mt!==s)){if(ct=H(s),Dt=H(s,i.draggable),typeof d=="function"){if(d.call(this,t,s,this)){x({sortable:e,rootEl:u,name:"filter",targetEl:s,toEl:n,fromEl:n}),N("filter",e,{evt:t}),r&&t.preventDefault();return}}else if(d&&(d=d.split(",").some(function(f){if(f=B(u,f.trim(),n,!1),f)return x({sortable:e,rootEl:f,name:"filter",targetEl:s,fromEl:n,toEl:n}),N("filter",e,{evt:t}),!0}),d)){r&&t.preventDefault();return}i.handle&&!B(u,i.handle,n,!1)||this._prepareDragStart(t,l,s)}}},_prepareDragStart:function(t,e,n){var i=this,r=i.el,a=i.options,l=r.ownerDocument,s;if(n&&!c&&n.parentNode===r){var u=A(n);if(S=r,c=n,_=c.parentNode,at=c.nextSibling,Mt=n,Ot=a.group,p.dragged=c,it={target:c,clientX:(e||t).clientX,clientY:(e||t).clientY},pe=it.clientX-u.left,ge=it.clientY-u.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,c.style["will-change"]="all",s=function(){if(N("delayEnded",i,{evt:t}),p.eventCanceled){i._onDrop();return}i._disableDelayedDragEvents(),!ce&&i.nativeDraggable&&(c.draggable=!0),i._triggerDragStart(t,e),x({sortable:i,name:"choose",originalEvent:t}),k(c,a.chosenClass,!0)},a.ignore.split(",").forEach(function(d){Ee(c,d.trim(),zt)}),v(l,"dragover",rt),v(l,"mousemove",rt),v(l,"touchmove",rt),a.supportPointer?(v(l,"pointerup",i._onDrop),!this.nativeDraggable&&v(l,"pointercancel",i._onDrop)):(v(l,"mouseup",i._onDrop),v(l,"touchend",i._onDrop),v(l,"touchcancel",i._onDrop)),ce&&this.nativeDraggable&&(this.options.touchStartThreshold=4,c.draggable=!0),N("delayStart",this,{evt:t}),a.delay&&(!a.delayOnTouchOnly||e)&&(!this.nativeDraggable||!(At||U))){if(p.eventCanceled){this._onDrop();return}a.supportPointer?(v(l,"pointerup",i._disableDelayedDrag),v(l,"pointercancel",i._disableDelayedDrag)):(v(l,"mouseup",i._disableDelayedDrag),v(l,"touchend",i._disableDelayedDrag),v(l,"touchcancel",i._disableDelayedDrag)),v(l,"mousemove",i._delayedDragTouchMoveHandler),v(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&v(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(s,a.delay)}else s()}},_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(){c&&zt(c),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;m(t,"mouseup",this._disableDelayedDrag),m(t,"touchend",this._disableDelayedDrag),m(t,"touchcancel",this._disableDelayedDrag),m(t,"pointerup",this._disableDelayedDrag),m(t,"pointercancel",this._disableDelayedDrag),m(t,"mousemove",this._delayedDragTouchMoveHandler),m(t,"touchmove",this._delayedDragTouchMoveHandler),m(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||t.pointerType=="touch"&&t,!this.nativeDraggable||e?this.options.supportPointer?v(document,"pointermove",this._onTouchMove):e?v(document,"touchmove",this._onTouchMove):v(document,"mousemove",this._onTouchMove):(v(c,"dragend",this),v(S,"dragstart",this._onDragStart));try{document.selection?kt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch{}},_dragStarted:function(t,e){if(ut=!1,S&&c){N("dragStarted",this,{evt:e}),this.nativeDraggable&&v(document,"dragover",Je);var n=this.options;!t&&k(c,n.dragClass,!1),k(c,n.ghostClass,!0),p.active=this,t&&this._appendGhost(),x({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(X){this._lastX=X.clientX,this._lastY=X.clientY,Oe();for(var t=document.elementFromPoint(X.clientX,X.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(X.clientX,X.clientY),t!==e);)e=t;if(c.parentNode[P]._isOutsideThisEl(t),e)do{if(e[P]){var n=void 0;if(n=e[P]._onDragOver({clientX:X.clientX,clientY:X.clientY,target:t,rootEl:e}),n&&!this.options.dragoverBubble)break}t=e}while(e=ye(e));xe()}},_onTouchMove:function(t){if(it){var e=this.options,n=e.fallbackTolerance,i=e.fallbackOffset,r=t.touches?t.touches[0]:t,a=g&&ft(g,!0),l=g&&a&&a.a,s=g&&a&&a.d,u=Nt&&I&&he(I),d=(r.clientX-it.clientX+i.x)/(l||1)+(u?u[0]-Kt[0]:0)/(l||1),f=(r.clientY-it.clientY+i.y)/(s||1)+(u?u[1]-Kt[1]:0)/(s||1);if(!p.active&&!ut){if(n&&Math.max(Math.abs(r.clientX-this._lastX),Math.abs(r.clientY-this._lastY))=0&&(x({rootEl:_,name:"add",toEl:_,fromEl:S,originalEvent:t}),x({sortable:this,name:"remove",toEl:_,originalEvent:t}),x({rootEl:_,name:"sort",toEl:_,fromEl:S,originalEvent:t}),x({sortable:this,name:"sort",toEl:_,originalEvent:t})),T&&T.save()):F!==ct&&F>=0&&(x({sortable:this,name:"update",toEl:_,originalEvent:t}),x({sortable:this,name:"sort",toEl:_,originalEvent:t})),p.active&&((F==null||F===-1)&&(F=ct,Q=Dt),x({sortable:this,name:"end",toEl:_,originalEvent:t}),this.save()))),this._nulling()},_nulling:function(){N("nulling",this),S=c=_=g=at=D=Mt=J=it=X=bt=F=Q=ct=Dt=st=_t=T=Ot=p.dragged=p.ghost=p.clone=p.active=null;var t=this.el;Xt.forEach(function(e){t.contains(e)&&(e.checked=!0)}),Xt.length=jt=$t=0},handleEvent:function(t){switch(t.type){case"drop":case"dragend":this._onDrop(t);break;case"dragenter":case"dragover":c&&(this._onDragOver(t),tn(t));break;case"selectstart":t.preventDefault();break}},toArray:function(){for(var t=[],e,n=this.el.children,i=0,r=n.length,a=this.options;ii.right+r||o.clientY>n.bottom&&o.clientX>n.left:o.clientY>i.bottom+r||o.clientX>n.right&&o.clientY>n.top}function rn(o,t,e,n,i,r,a,l){var s=n?o.clientY:o.clientX,u=n?e.height:e.width,d=n?e.top:e.left,f=n?e.bottom:e.right,b=!1;if(!a){if(l&&Rtd+u*r/2:sf-Rt)return-_t}else if(s>d+u*(1-i)/2&&sf-u*r/2)?s>d+u/2?1:-1:0}function an(o){return H(c)this.setup()),this.$watch("isReordering",()=>{this.$nextTick(()=>this.setup())}),Livewire.hook("morph.updating",({el:t,skip:e})=>{if(!this.$root.contains(t))return;if(this.isDragging){e();return}let n=document.activeElement;n&&(n.tagName==="INPUT"||n.tagName==="TEXTAREA"||n.tagName==="SELECT")&&this.$root.contains(n)&&e()}),Livewire.hook("morph.updated",({el:t})=>{if(this.isDragging||!this.$root.contains(t))return;let e=document.activeElement;e&&(e.tagName==="INPUT"||e.tagName==="TEXTAREA"||e.tagName==="SELECT")&&this.$root.contains(e)||this.$nextTick(()=>this.setup())})},setup(){this.destroyRowSortable(),this.config.rowReorderable&&this.isReordering&&this.initRowSortable(),this.config.columnReorderable&&this.initColumnSortable()},initRowSortable(){let t=this.$root.querySelector("tbody");t&&(this.addRowDragHandles(t),this.rowSortableInstance=new le(t,{handle:".wire-sortable-handle",animation:this.config.animation,easing:"cubic-bezier(0.25, 1, 0.5, 1)",ghostClass:"wire-sortable-ghost",chosenClass:"wire-sortable-chosen",dragClass:"wire-sortable-drag",forceFallback:!0,fallbackClass:"wire-sortable-fallback",fallbackTolerance:3,scrollSensitivity:80,scrollSpeed:12,onChoose:e=>{this.lockTableCellWidths(t)},onUnchoose:()=>{this.unlockTableCellWidths(t)},onStart:e=>{this.isDragging=!0,this.pausePolling(),document.body.classList.add("wire-sortable-active"),e.item.style.height=e.item.offsetHeight+"px"},onEnd:e=>{if(this.isDragging=!1,this.resumePolling(),document.body.classList.remove("wire-sortable-active"),e.item.style.height="",this.unlockTableCellWidths(t),e.oldIndex===e.newIndex)return;let n=t.querySelectorAll("tr[wire\\:key]"),i=[];n.forEach((r,a)=>{let l=r.getAttribute("wire:key"),s=l?l.replace("row-",""):null;s&&i.push({value:s,order:a+1})}),i.length>0&&this.getLivewireComponent()?.call("reorderRows",i)}}))},lockTableCellWidths(t){let e=t.closest("table");e&&(e.querySelectorAll("thead th").forEach(n=>{n.style.width=n.offsetWidth+"px"}),t.querySelectorAll("tr").forEach(n=>{n.querySelectorAll("td").forEach(i=>{i.style.width=i.offsetWidth+"px",i.style.minWidth=i.offsetWidth+"px",i.style.maxWidth=i.offsetWidth+"px"})}),e.style.tableLayout="fixed",e.style.width=e.offsetWidth+"px")},unlockTableCellWidths(t){let e=t?.closest("table");e&&(e.style.tableLayout="",e.style.width="",e.querySelectorAll("thead th").forEach(n=>{n.style.width=""}),t.querySelectorAll("td").forEach(n=>{n.style.width="",n.style.minWidth="",n.style.maxWidth=""}))},addRowDragHandles(t){let e=t.closest("table");if(!e)return;let n=e.querySelector("thead");n&&!n.querySelector(".wire-sortable-th")&&n.querySelectorAll("tr").forEach(i=>{let r=document.createElement("th");r.className="wire-sortable-th",r.scope="col",i.prepend(r)}),t.querySelectorAll("tr").forEach(i=>{if(i.querySelector(".wire-sortable-handle"))return;let r=document.createElement("td");r.className="wire-sortable-handle-cell",r.innerHTML=this.getDragHandleHtml(),i.prepend(r)})},destroyRowSortable(){this.rowSortableInstance&&(this.rowSortableInstance.destroy(),this.rowSortableInstance=null);let t=this.$root.querySelector("tbody");t&&(this.unlockTableCellWidths(t),t.querySelectorAll(".wire-sortable-handle-cell").forEach(n=>n.remove()));let e=this.$root.querySelector("thead");e&&e.querySelectorAll(".wire-sortable-th").forEach(n=>n.remove())},getDragHandleHtml(){return this.config.dragHandleHtml},initColumnSortable(){let t=this.$root.querySelector("thead tr");if(!t)return;this.columnSortableInstance&&(this.columnSortableInstance.destroy(),this.columnSortableInstance=null),this.markHeaderCells(t);let e="th[data-sortable-column]";this.columnSortableInstance=new le(t,{animation:this.config.animation,easing:"cubic-bezier(0.25, 1, 0.5, 1)",draggable:e,ghostClass:"wire-sortable-column-ghost",chosenClass:"wire-sortable-column-chosen",dragClass:"wire-sortable-column-drag",direction:"horizontal",forceFallback:!0,fallbackClass:"wire-sortable-column-fallback",fallbackTolerance:3,onStart:()=>{this.isDragging=!0,this.pausePolling()},onEnd:n=>{if(this.isDragging=!1,this.resumePolling(),n.oldIndex===n.newIndex)return;let i=t.querySelectorAll("th[data-sortable-column]"),r=[];i.forEach(a=>{let l=a.getAttribute("data-sortable-column");l&&r.push(l)}),this.reorderBodyColumns(r),r.length>0&&this.getLivewireComponent()?.call("reorderColumns",r)}})},markHeaderCells(t){t.querySelectorAll("th").forEach(e=>{if(e.hasAttribute("data-sortable-column")||e.classList.contains("wire-sortable-th"))return;let n=e.querySelector('[wire\\:click*="sortTable"]');if(n){let r=n.getAttribute("wire:click")?.match(/sortTable\('([^']+)'\)/);if(r){e.setAttribute("data-sortable-column",r[1]),e.style.cursor="grab";return}}let i=e.getAttribute("data-column");i&&(e.setAttribute("data-sortable-column",i),e.style.cursor="grab")})},reorderBodyColumns(t){let e=this.$root.querySelector("tbody");!e||!t||t.length===0||Array.from(e.children).filter(n=>n.matches("tr[data-row-key]")).forEach(n=>{let i=new Map;if(Array.from(n.children).forEach(l=>{let s=l.getAttribute&&l.getAttribute("data-column");s&&i.set(s,l)}),i.size===0)return;let r=Array.from(i.values()),a=r[r.length-1].nextSibling;t.forEach(l=>{let s=i.get(l);s&&n.insertBefore(s,a)})})},pausePolling(){let t=this.$root.closest("[wire\\:id]");if(!t)return;let e=t.querySelector("[wire\\:poll]")||(t.hasAttribute("wire:poll")?t:null);if(!e)return;let n=e.getAttributeNames().filter(i=>i.startsWith("wire:poll"));n.length!==0&&(this._pausedPoll=n.map(i=>({el:e,attr:i,value:e.getAttribute(i)})),n.forEach(i=>e.removeAttribute(i)))},resumePolling(){this._pausedPoll&&(this._pausedPoll.forEach(({el:t,attr:e,value:n})=>{t.setAttribute(e,n||"")}),this._pausedPoll=null)},getLivewireComponent(){let t=this.$root.closest("[wire\\:id]");return t?Livewire.find(t.getAttribute("wire:id")):null},destroy(){this.destroyRowSortable(),this.columnSortableInstance&&(this.columnSortableInstance.destroy(),this.columnSortableInstance=null)}}}var Pe=!1;function Me(){Pe||!window.Alpine||(Pe=!0,window.Alpine.data("wireSortable",cn))}window.Alpine?Me():document.addEventListener("alpine:init",Me);})(); +(()=>{function Le(o,t,e){return(t=Ge(t))in o?Object.defineProperty(o,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):o[t]=e,o}function z(){return z=Object.assign?Object.assign.bind():function(o){for(var t=1;t"&&(t=t.substring(1)),o)try{if(o.matches)return o.matches(t);if(o.msMatchesSelector)return o.msMatchesSelector(t);if(o.webkitMatchesSelector)return o.webkitMatchesSelector(t)}catch{return!1}return!1}}function Se(o){return o.host&&o!==document&&o.host.nodeType&&o.host!==o?o.host:o.parentNode}function X(o,t,e,n){if(o){e=e||document;do{if(t!=null&&(t[0]===">"?o.parentNode===e&&Ht(o,t):Ht(o,t))||n&&o===e)return o;if(o===e)break}while(o=Se(o))}return null}var fe=/\s+/g;function k(o,t,e){if(o&&t)if(o.classList)o.classList[e?"add":"remove"](t);else{var n=(" "+o.className+" ").replace(fe," ").replace(" "+t+" "," ");o.className=(n+(e?" "+t:"")).replace(fe," ")}}function h(o,t,e){var n=o&&o.style;if(n){if(e===void 0)return document.defaultView&&document.defaultView.getComputedStyle?e=document.defaultView.getComputedStyle(o,""):o.currentStyle&&(e=o.currentStyle),t===void 0?e:e[t];!(t in n)&&t.indexOf("webkit")===-1&&(t="-webkit-"+t),n[t]=e+(typeof e=="string"?"":"px")}}function dt(o,t){var e="";if(typeof o=="string")e=o;else do{var n=h(o,"transform");n&&n!=="none"&&(e=n+" "+e)}while(!t&&(o=o.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(e)}function Ee(o,t,e){if(o){var n=o.getElementsByTagName(t),i=0,r=n.length;if(e)for(;i=r:a=i<=r,!a)return n;if(n===G())break;n=tt(n,!1)}return!1}function ft(o,t,e,n){for(var i=0,r=0,a=o.children;r2&&arguments[2]!==void 0?arguments[2]:{},i=n.evt,r=Be(n,Ze);Tt.pluginEvent.bind(p)(t,e,q({dragEl:c,parentEl:_,ghostEl:g,rootEl:E,nextEl:at,lastDownEl:Mt,cloneEl:D,cloneHidden:J,dragStarted:bt,putSortable:T,activeSortable:p.active,originalEvent:i,oldIndex:ct,oldDraggableIndex:Dt,newIndex:F,newDraggableIndex:Z,hideGhostForTarget:xe,unhideGhostForTarget:Pe,cloneNowHidden:function(){J=!0},cloneNowShown:function(){J=!1},dispatchSortableEvent:function(l){x({sortable:e,name:l,originalEvent:i})}},r))};function x(o){Qe(q({putSortable:T,cloneEl:D,targetEl:c,rootEl:E,oldIndex:ct,oldDraggableIndex:Dt,newIndex:F,newDraggableIndex:Z},o))}var c,_,g,E,at,Mt,D,J,ct,F,Dt,Z,Ot,T,ut=!1,Wt=!1,Lt=[],it,B,$t,Kt,ge,me,bt,st,_t,Ct=!1,xt=!1,Rt,I,zt=[],te=!1,Bt=[],Yt=typeof document<"u",Pt=ie,ve=At||U?"cssFloat":"float",Je=Yt&&!we&&!ie&&"draggable"in document.createElement("div"),Te=(function(){if(Yt){if(U)return!1;var o=document.createElement("x");return o.style.cssText="pointer-events:auto",o.style.pointerEvents==="auto"}})(),Ie=function(t,e){var n=h(t),i=parseInt(n.width)-parseInt(n.paddingLeft)-parseInt(n.paddingRight)-parseInt(n.borderLeftWidth)-parseInt(n.borderRightWidth),r=ft(t,0,e),a=ft(t,1,e),l=r&&h(r),s=a&&h(a),u=l&&parseInt(l.marginLeft)+parseInt(l.marginRight)+A(r).width,f=s&&parseInt(s.marginLeft)+parseInt(s.marginRight)+A(a).width;if(n.display==="flex")return n.flexDirection==="column"||n.flexDirection==="column-reverse"?"vertical":"horizontal";if(n.display==="grid")return n.gridTemplateColumns.split(" ").length<=1?"vertical":"horizontal";if(r&&l.float&&l.float!=="none"){var d=l.float==="left"?"left":"right";return a&&(s.clear==="both"||s.clear===d)?"vertical":"horizontal"}return r&&(l.display==="block"||l.display==="flex"||l.display==="table"||l.display==="grid"||u>=i&&n[ve]==="none"||a&&n[ve]==="none"&&u+f>i)?"vertical":"horizontal"},tn=function(t,e,n){var i=n?t.left:t.top,r=n?t.right:t.bottom,a=n?t.width:t.height,l=n?e.left:e.top,s=n?e.right:e.bottom,u=n?e.width:e.height;return i===l||r===s||i+a/2===l+u/2},en=function(t,e){var n;return Lt.some(function(i){var r=i[N].options.emptyInsertThreshold;if(!(!r||re(i))){var a=A(i),l=t>=a.left-r&&t<=a.right+r,s=e>=a.top-r&&e<=a.bottom+r;if(l&&s)return n=i}}),n},Oe=function(t){function e(r,a){return function(l,s,u,f){var d=l.options.group.name&&s.options.group.name&&l.options.group.name===s.options.group.name;if(r==null&&(a||d))return!0;if(r==null||r===!1)return!1;if(a&&r==="clone")return r;if(typeof r=="function")return e(r(l,s,u,f),a)(l,s,u,f);var b=(a?l:s).options.group.name;return r===!0||typeof r=="string"&&r===b||r.join&&r.indexOf(b)>-1}}var n={},i=t.group;(!i||Jt(i)!="object")&&(i={name:i}),n.name=i.name,n.checkPull=e(i.pull,!0),n.checkPut=e(i.put),n.revertClone=i.revertClone,t.group=n},xe=function(){!Te&&g&&h(g,"display","none")},Pe=function(){!Te&&g&&h(g,"display","")};Yt&&!we&&document.addEventListener("click",function(o){if(Wt)return o.preventDefault(),o.stopPropagation&&o.stopPropagation(),o.stopImmediatePropagation&&o.stopImmediatePropagation(),Wt=!1,!1},!0);var rt=function(t){if(c){t=t.touches?t.touches[0]:t;var e=en(t.clientX,t.clientY);if(e){var n={};for(var i in t)t.hasOwnProperty(i)&&(n[i]=t[i]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[N]._onDragOver(n)}}},nn=function(t){c&&c.parentNode[N]._isOutsideThisEl(t.target)};function p(o,t){if(!(o&&o.nodeType&&o.nodeType===1))throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(o));this.el=o,this.options=t=z({},t),o[N]=this;var e={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(o.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Ie(o,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(a,l){a.setData("Text",l.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:p.supportPointer!==!1&&"PointerEvent"in window&&(!St||ie),emptyInsertThreshold:5};Tt.initializePlugins(this,o,e);for(var n in e)!(n in t)&&(t[n]=e[n]);Oe(t);for(var i in this)i.charAt(0)==="_"&&typeof this[i]=="function"&&(this[i]=this[i].bind(this));this.nativeDraggable=t.forceFallback?!1:Je,this.nativeDraggable&&(this.options.touchStartThreshold=1),t.supportPointer?v(o,"pointerdown",this._onTapStart):(v(o,"mousedown",this._onTapStart),v(o,"touchstart",this._onTapStart)),this.nativeDraggable&&(v(o,"dragover",this),v(o,"dragenter",this)),Lt.push(this.el),t.store&&t.store.get&&this.sort(t.store.get(this)||[]),z(this,ze())}p.prototype={constructor:p,_isOutsideThisEl:function(t){!this.el.contains(t)&&t!==this.el&&(st=null)},_getDirection:function(t,e){return typeof this.options.direction=="function"?this.options.direction.call(this,t,e,c):this.options.direction},_onTapStart:function(t){if(t.cancelable){var e=this,n=this.el,i=this.options,r=i.preventOnFilter,a=t.type,l=t.touches&&t.touches[0]||t.pointerType&&t.pointerType==="touch"&&t,s=(l||t).target,u=t.target.shadowRoot&&(t.path&&t.path[0]||t.composedPath&&t.composedPath()[0])||s,f=i.filter;if(dn(n),!c&&!(/mousedown|pointerdown/.test(a)&&t.button!==0||i.disabled)&&!u.isContentEditable&&!(!this.nativeDraggable&&St&&s&&s.tagName.toUpperCase()==="SELECT")&&(s=X(s,i.draggable,n,!1),!(s&&s.animated)&&Mt!==s)){if(ct=H(s),Dt=H(s,i.draggable),typeof f=="function"){if(f.call(this,t,s,this)){x({sortable:e,rootEl:u,name:"filter",targetEl:s,toEl:n,fromEl:n}),P("filter",e,{evt:t}),r&&t.preventDefault();return}}else if(f&&(f=f.split(",").some(function(d){if(d=X(u,d.trim(),n,!1),d)return x({sortable:e,rootEl:d,name:"filter",targetEl:s,fromEl:n,toEl:n}),P("filter",e,{evt:t}),!0}),f)){r&&t.preventDefault();return}i.handle&&!X(u,i.handle,n,!1)||this._prepareDragStart(t,l,s)}}},_prepareDragStart:function(t,e,n){var i=this,r=i.el,a=i.options,l=r.ownerDocument,s;if(n&&!c&&n.parentNode===r){var u=A(n);if(E=r,c=n,_=c.parentNode,at=c.nextSibling,Mt=n,Ot=a.group,p.dragged=c,it={target:c,clientX:(e||t).clientX,clientY:(e||t).clientY},ge=it.clientX-u.left,me=it.clientY-u.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,c.style["will-change"]="all",s=function(){if(P("delayEnded",i,{evt:t}),p.eventCanceled){i._onDrop();return}i._disableDelayedDragEvents(),!de&&i.nativeDraggable&&(c.draggable=!0),i._triggerDragStart(t,e),x({sortable:i,name:"choose",originalEvent:t}),k(c,a.chosenClass,!0)},a.ignore.split(",").forEach(function(f){Ee(c,f.trim(),Ut)}),v(l,"dragover",rt),v(l,"mousemove",rt),v(l,"touchmove",rt),a.supportPointer?(v(l,"pointerup",i._onDrop),!this.nativeDraggable&&v(l,"pointercancel",i._onDrop)):(v(l,"mouseup",i._onDrop),v(l,"touchend",i._onDrop),v(l,"touchcancel",i._onDrop)),de&&this.nativeDraggable&&(this.options.touchStartThreshold=4,c.draggable=!0),P("delayStart",this,{evt:t}),a.delay&&(!a.delayOnTouchOnly||e)&&(!this.nativeDraggable||!(At||U))){if(p.eventCanceled){this._onDrop();return}a.supportPointer?(v(l,"pointerup",i._disableDelayedDrag),v(l,"pointercancel",i._disableDelayedDrag)):(v(l,"mouseup",i._disableDelayedDrag),v(l,"touchend",i._disableDelayedDrag),v(l,"touchcancel",i._disableDelayedDrag)),v(l,"mousemove",i._delayedDragTouchMoveHandler),v(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&v(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(s,a.delay)}else s()}},_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(){c&&Ut(c),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;m(t,"mouseup",this._disableDelayedDrag),m(t,"touchend",this._disableDelayedDrag),m(t,"touchcancel",this._disableDelayedDrag),m(t,"pointerup",this._disableDelayedDrag),m(t,"pointercancel",this._disableDelayedDrag),m(t,"mousemove",this._delayedDragTouchMoveHandler),m(t,"touchmove",this._delayedDragTouchMoveHandler),m(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||t.pointerType=="touch"&&t,!this.nativeDraggable||e?this.options.supportPointer?v(document,"pointermove",this._onTouchMove):e?v(document,"touchmove",this._onTouchMove):v(document,"mousemove",this._onTouchMove):(v(c,"dragend",this),v(E,"dragstart",this._onDragStart));try{document.selection?kt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch{}},_dragStarted:function(t,e){if(ut=!1,E&&c){P("dragStarted",this,{evt:e}),this.nativeDraggable&&v(document,"dragover",nn);var n=this.options;!t&&k(c,n.dragClass,!1),k(c,n.ghostClass,!0),p.active=this,t&&this._appendGhost(),x({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(B){this._lastX=B.clientX,this._lastY=B.clientY,xe();for(var t=document.elementFromPoint(B.clientX,B.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(B.clientX,B.clientY),t!==e);)e=t;if(c.parentNode[N]._isOutsideThisEl(t),e)do{if(e[N]){var n=void 0;if(n=e[N]._onDragOver({clientX:B.clientX,clientY:B.clientY,target:t,rootEl:e}),n&&!this.options.dragoverBubble)break}t=e}while(e=Se(e));Pe()}},_onTouchMove:function(t){if(it){var e=this.options,n=e.fallbackTolerance,i=e.fallbackOffset,r=t.touches?t.touches[0]:t,a=g&&dt(g,!0),l=g&&a&&a.a,s=g&&a&&a.d,u=Pt&&I&&pe(I),f=(r.clientX-it.clientX+i.x)/(l||1)+(u?u[0]-zt[0]:0)/(l||1),d=(r.clientY-it.clientY+i.y)/(s||1)+(u?u[1]-zt[1]:0)/(s||1);if(!p.active&&!ut){if(n&&Math.max(Math.abs(r.clientX-this._lastX),Math.abs(r.clientY-this._lastY))=0&&(x({rootEl:_,name:"add",toEl:_,fromEl:E,originalEvent:t}),x({sortable:this,name:"remove",toEl:_,originalEvent:t}),x({rootEl:_,name:"sort",toEl:_,fromEl:E,originalEvent:t}),x({sortable:this,name:"sort",toEl:_,originalEvent:t})),T&&T.save()):F!==ct&&F>=0&&(x({sortable:this,name:"update",toEl:_,originalEvent:t}),x({sortable:this,name:"sort",toEl:_,originalEvent:t})),p.active&&((F==null||F===-1)&&(F=ct,Z=Dt),x({sortable:this,name:"end",toEl:_,originalEvent:t}),this.save()))),this._nulling()},_nulling:function(){P("nulling",this),E=c=_=g=at=D=Mt=J=it=B=bt=F=Z=ct=Dt=st=_t=T=Ot=p.dragged=p.ghost=p.clone=p.active=null;var t=this.el;Bt.forEach(function(e){t.contains(e)&&(e.checked=!0)}),Bt.length=$t=Kt=0},handleEvent:function(t){switch(t.type){case"drop":case"dragend":this._onDrop(t);break;case"dragenter":case"dragover":c&&(this._onDragOver(t),on(t));break;case"selectstart":t.preventDefault();break}},toArray:function(){for(var t=[],e,n=this.el.children,i=0,r=n.length,a=this.options;ii.right+r||o.clientY>n.bottom&&o.clientX>n.left:o.clientY>i.bottom+r||o.clientX>n.right&&o.clientY>n.top}function sn(o,t,e,n,i,r,a,l){var s=n?o.clientY:o.clientX,u=n?e.height:e.width,f=n?e.top:e.left,d=n?e.bottom:e.right,b=!1;if(!a){if(l&&Rtf+u*r/2:sd-Rt)return-_t}else if(s>f+u*(1-i)/2&&sd-u*r/2)?s>f+u/2?1:-1:0}function un(o){return H(c){Gt.forEach((t,e)=>{if(!e.isConnected){Gt.delete(e);return}o(t)})},hn=()=>{Me||!window.Livewire||(Me=!0,window.Livewire.hook("morph.updating",({el:o,skip:t})=>{Re(e=>e.onMorphUpdating(o,t))}),window.Livewire.hook("morph.updated",({el:o})=>{Re(t=>t.onMorphUpdated(o))}))};function pn(o={}){return{rowSortableInstance:null,columnSortableInstance:null,isDragging:!1,config:{rowReorderable:o.rowReorderable??!1,columnReorderable:o.columnReorderable??!1,orderColumn:o.orderColumn??"sort_order",animation:o.animation??150,dragHandleHtml:o.dragHandleHtml??""},isReordering:o.isReordering??!1,init(){this.scheduleSetup(),this.$watch("isReordering",()=>{this.scheduleSetup()}),Gt.set(this.$root,this),hn()},onMorphUpdating(t,e){if(!this.$root.contains(t))return;if(this.isDragging){e();return}let n=this.editingCell();n&&t===n&&e()},onMorphUpdated(t){this.isDragging||!this.$root.contains(t)||this.editingCell()||this.scheduleSetup()},editingCell(){let t=document.activeElement;return!t||!this.$root.contains(t)||!t.closest?null:t.closest("[data-record-key][data-column-name]")},scheduleSetup(){this._setupQueued||(this._setupQueued=!0,this.$nextTick(()=>{this._setupQueued=!1,this.setup()}))},setup(){this.destroyRowSortable(),this.config.rowReorderable&&this.isReordering&&this.initRowSortable(),this.config.columnReorderable&&this.initColumnSortable()},initRowSortable(){let t=this.$root.querySelector("tbody");t&&(this.addRowDragHandles(t),this.rowSortableInstance=new se(t,{handle:".wire-sortable-handle",animation:this.config.animation,easing:"cubic-bezier(0.25, 1, 0.5, 1)",ghostClass:"wire-sortable-ghost",chosenClass:"wire-sortable-chosen",dragClass:"wire-sortable-drag",forceFallback:!0,fallbackClass:"wire-sortable-fallback",fallbackTolerance:3,scrollSensitivity:80,scrollSpeed:12,onChoose:e=>{this.lockTableCellWidths(t)},onUnchoose:()=>{this.unlockTableCellWidths(t)},onStart:e=>{this.isDragging=!0,this.pausePolling(),document.body.classList.add("wire-sortable-active"),e.item.style.height=e.item.offsetHeight+"px"},onEnd:e=>{if(this.isDragging=!1,this.resumePolling(),document.body.classList.remove("wire-sortable-active"),e.item.style.height="",this.unlockTableCellWidths(t),e.oldIndex===e.newIndex)return;let n=t.querySelectorAll("tr[wire\\:key]"),i=[];n.forEach((r,a)=>{let l=r.getAttribute("wire:key"),s=l?l.replace("row-",""):null;s&&i.push({value:s,order:a+1})}),i.length>0&&this.getLivewireComponent()?.call("reorderRows",i)}}))},lockTableCellWidths(t){let e=t.closest("table");e&&(e.querySelectorAll("thead th").forEach(n=>{n.style.width=n.offsetWidth+"px"}),t.querySelectorAll("tr").forEach(n=>{n.querySelectorAll("td").forEach(i=>{i.style.width=i.offsetWidth+"px",i.style.minWidth=i.offsetWidth+"px",i.style.maxWidth=i.offsetWidth+"px"})}),e.style.tableLayout="fixed",e.style.width=e.offsetWidth+"px")},unlockTableCellWidths(t){let e=t?.closest("table");e&&(e.style.tableLayout="",e.style.width="",e.querySelectorAll("thead th").forEach(n=>{n.style.width=""}),t.querySelectorAll("td").forEach(n=>{n.style.width="",n.style.minWidth="",n.style.maxWidth=""}))},addRowDragHandles(t){let e=t.closest("table");if(!e)return;let n=e.querySelector("thead");n&&!n.querySelector(".wire-sortable-th")&&n.querySelectorAll("tr").forEach(i=>{let r=document.createElement("th");r.className="wire-sortable-th",r.scope="col",i.prepend(r)}),t.querySelectorAll("tr").forEach(i=>{if(i.querySelector(".wire-sortable-handle"))return;let r=document.createElement("td");r.className="wire-sortable-handle-cell",r.innerHTML=this.getDragHandleHtml(),i.prepend(r)})},destroyRowSortable(){this.rowSortableInstance&&(this.rowSortableInstance.destroy(),this.rowSortableInstance=null);let t=this.$root.querySelector("tbody");t&&(this.unlockTableCellWidths(t),t.querySelectorAll(".wire-sortable-handle-cell").forEach(n=>n.remove()));let e=this.$root.querySelector("thead");e&&e.querySelectorAll(".wire-sortable-th").forEach(n=>n.remove())},getDragHandleHtml(){return this.config.dragHandleHtml},initColumnSortable(){let t=this.$root.querySelector("thead tr");if(!t)return;this.columnSortableInstance&&(this.columnSortableInstance.destroy(),this.columnSortableInstance=null),this.markHeaderCells(t);let e="th[data-sortable-column]";this.columnSortableInstance=new se(t,{animation:this.config.animation,easing:"cubic-bezier(0.25, 1, 0.5, 1)",draggable:e,ghostClass:"wire-sortable-column-ghost",chosenClass:"wire-sortable-column-chosen",dragClass:"wire-sortable-column-drag",direction:"horizontal",forceFallback:!0,fallbackClass:"wire-sortable-column-fallback",fallbackTolerance:3,onStart:()=>{this.isDragging=!0,this.pausePolling()},onEnd:n=>{if(this.isDragging=!1,this.resumePolling(),n.oldIndex===n.newIndex)return;let i=t.querySelectorAll("th[data-sortable-column]"),r=[];i.forEach(a=>{let l=a.getAttribute("data-sortable-column");l&&r.push(l)}),this.reorderBodyColumns(r),r.length>0&&this.getLivewireComponent()?.call("reorderColumns",r)}})},markHeaderCells(t){t.querySelectorAll("th").forEach(e=>{if(e.hasAttribute("data-sortable-column")||e.classList.contains("wire-sortable-th"))return;let n=e.querySelector('[wire\\:click*="sortTable"]');if(n){let r=n.getAttribute("wire:click")?.match(/sortTable\('([^']+)'\)/);if(r){e.setAttribute("data-sortable-column",r[1]),e.style.cursor="grab";return}}let i=e.getAttribute("data-column");i&&(e.setAttribute("data-sortable-column",i),e.style.cursor="grab")})},reorderBodyColumns(t){let e=this.$root.querySelector("tbody");!e||!t||t.length===0||Array.from(e.children).filter(n=>n.matches("tr[data-row-key]")).forEach(n=>{let i=new Map;if(Array.from(n.children).forEach(l=>{let s=l.getAttribute&&l.getAttribute("data-column");s&&i.set(s,l)}),i.size===0)return;let r=Array.from(i.values()),a=r[r.length-1].nextSibling;t.forEach(l=>{let s=i.get(l);s&&n.insertBefore(s,a)})})},pausePolling(){let t=this.$root.closest("[wire\\:id]");if(!t)return;let e=t.querySelector("[wire\\:poll]")||(t.hasAttribute("wire:poll")?t:null);if(!e)return;let n=e.getAttributeNames().filter(i=>i.startsWith("wire:poll"));n.length!==0&&(this._pausedPoll=n.map(i=>({el:e,attr:i,value:e.getAttribute(i)})),n.forEach(i=>e.removeAttribute(i)))},resumePolling(){this._pausedPoll&&(this._pausedPoll.forEach(({el:t,attr:e,value:n})=>{t.setAttribute(e,n||"")}),this._pausedPoll=null)},getLivewireComponent(){let t=this.$root.closest("[wire\\:id]");return t?Livewire.find(t.getAttribute("wire:id")):null},destroy(){Gt.delete(this.$root),this.destroyRowSortable(),this.columnSortableInstance&&(this.columnSortableInstance.destroy(),this.columnSortableInstance=null)}}}var ke=!1;function Fe(){ke||!window.Alpine||(ke=!0,window.Alpine.data("wireSortable",pn))}window.Alpine?Fe():document.addEventListener("alpine:init",Fe);})(); diff --git a/packages/sortable/resources/js/sortable.js b/packages/sortable/resources/js/sortable.js index 93f0b41a..3ede9226 100644 --- a/packages/sortable/resources/js/sortable.js +++ b/packages/sortable/resources/js/sortable.js @@ -15,6 +15,48 @@ import Sortable from 'sortablejs'; +// One pair of morph hooks for the page rather than one pair per table: +// Livewire's hook() has no off switch, so registering inside init() stacks a +// fresh pair every time a controller initialises — a second reorderable table, +// a wire:navigate, a table inside a lazily loaded modal — and every stacked +// copy keeps running against a component that no longer exists. Same pattern as +// wire-table's record-actions guard and the fill handle's. +// +// Keyed by the wrapper element rather than held in a Set, because a controller +// cannot reliably remove *itself*: Alpine calls destroy() with a merge proxy of +// the scope, not the instance init() saw, so `delete(this)` deletes nothing. +// The element is the same object in both, and keying by it also means a +// controller replacing another on the same wrapper takes over its entry instead +// of joining it. +const controllers = new Map(); +let morphGuardsInstalled = false; + +/** Drop controllers whose wrapper has left the document, then run the rest. */ +const eachController = (run) => { + controllers.forEach((controller, root) => { + if (!root.isConnected) { + controllers.delete(root); + return; + } + + run(controller); + }); +}; + +const installMorphGuards = () => { + if (morphGuardsInstalled || !window.Livewire) return; + + morphGuardsInstalled = true; + + window.Livewire.hook('morph.updating', ({ el, skip }) => { + eachController((controller) => controller.onMorphUpdating(el, skip)); + }); + + window.Livewire.hook('morph.updated', ({ el }) => { + eachController((controller) => controller.onMorphUpdated(el)); + }); +}; + export function wireSortable(config = {}) { return { rowSortableInstance: null, @@ -30,45 +72,87 @@ export function wireSortable(config = {}) { isReordering: config.isReordering ?? false, init() { - this.$nextTick(() => this.setup()); + this.scheduleSetup(); this.$watch('isReordering', () => { - this.$nextTick(() => this.setup()); + this.scheduleSetup(); }); - // Block Livewire morph during drag or inline editing to prevent DOM disruption. - // setup() re-creates drag-handle cells which collapses the table - // layout and kills focus, and morphing itself can replace the focused - // input element. - Livewire.hook('morph.updating', ({ el, skip }) => { - if (!this.$root.contains(el)) return; + // The morph guards below run from the page-wide hooks, for as long + // as this controller owns the wrapper. + controllers.set(this.$root, this); + installMorphGuards(); + }, - if (this.isDragging) { - skip(); - return; - } + /** + * Block the morph during a drag, and over the one cell being edited — + * dragging moves rows the server render knows nothing about, and + * morphing an editable cell can replace the input being typed into. + * + * Both guards have to name what they protect. `skip()` takes the whole + * subtree of `el` with it and `contains()` is inclusive, so the old + * "is any input inside the table focused?" test — answered for every + * node from the wrapper down — skipped the wrapper itself, and with it + * the entire table. The search box is an input inside the table: typing + * in it silenced every render it asked for, and the table simply + * stopped responding to search. + */ + onMorphUpdating(el, skip) { + if (!this.$root.contains(el)) return; - const active = document.activeElement; - if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.tagName === 'SELECT') - && this.$root.contains(active)) { - skip(); - } - }); + if (this.isDragging) { + skip(); + return; + } - // Re-initialize after Livewire morphs (pagination, filters, etc.) - Livewire.hook('morph.updated', ({ el }) => { - if (this.isDragging || !this.$root.contains(el)) return; + const cell = this.editingCell(); + if (cell && el === cell) skip(); + }, - // Skip re-init when a table input is focused — setup() destroys - // and re-creates drag-handle cells which collapses the table - // layout and kills focus on editable columns. - const active = document.activeElement; - if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.tagName === 'SELECT') - && this.$root.contains(active)) { - return; - } + /** Re-initialize after Livewire morphs (pagination, filters, etc.). */ + onMorphUpdated(el) { + if (this.isDragging || !this.$root.contains(el)) return; + + // Not while a cell is being edited: setup() destroys and re-creates + // the drag-handle cells, which collapses the table layout and + // kills focus. Focus anywhere else in the table — the search box, a + // filter, the per-page select — is outside and survives it. + if (this.editingCell()) return; - this.$nextTick(() => this.setup()); + this.scheduleSetup(); + }, + + /** + * The editable cell the user is currently typing in, if any. + * + * Identified by the `[data-record-key][data-column-name]` pair the + * editable columns render, which is the selector wireTableLive already + * reads in busy() — no new convention, and one place to change if the + * cell markup ever does. + */ + editingCell() { + const active = document.activeElement; + + if (!active || !this.$root.contains(active) || !active.closest) return null; + + return active.closest('[data-record-key][data-column-name]'); + }, + + /** + * One setup() per morph, not one per morphed node. + * + * `morph.updated` fires for every element Livewire patches, which over + * a table is hundreds of nodes — each of them used to tear down and + * rebuild both Sortable instances. + */ + scheduleSetup() { + if (this._setupQueued) return; + + this._setupQueued = true; + + this.$nextTick(() => { + this._setupQueued = false; + this.setup(); }); }, @@ -409,6 +493,14 @@ export function wireSortable(config = {}) { }, destroy() { + // Alpine calls this when the tree carrying the wrapper is torn + // down. Leaving the entry behind would keep a destroyed component + // answering for the table: its $root is often the very same element + // the next controller drives, so a stale `isDragging` would go on + // blocking morphs for the whole page. Removed by element, since + // `this` here is not the instance that registered (see above). + controllers.delete(this.$root); + this.destroyRowSortable(); if (this.columnSortableInstance) { this.columnSortableInstance.destroy(); diff --git a/packages/sortable/tests/Feature/MorphGuardTest.php b/packages/sortable/tests/Feature/MorphGuardTest.php new file mode 100644 index 00000000..7ce53029 --- /dev/null +++ b/packages/sortable/tests/Feature/MorphGuardTest.php @@ -0,0 +1,201 @@ +model(MgTask::class) + ->columnReorderable() + ->columns([ + TextColumn::make('title')->searchable(), + TextInputColumn::make('owner_name'), + ]) + ->paginated(false); + } + + public function render() + { + return $this->getTableProperty(); + } +} + +/** The same table with neither kind of reordering asked for. */ +class MgPlainHost extends Component +{ + use WithSortable; + use WithTable; + + public function table(Table $table): Table + { + return $table + ->model(MgTask::class) + ->columns([ + TextColumn::make('title')->searchable(), + TextInputColumn::make('owner_name'), + ]) + ->paginated(false); + } + + public function render() + { + return $this->getTableProperty(); + } +} + +function mgSource(): string +{ + return file_get_contents( + dirname(WireSortableServiceProvider::ASSETS_PATH).'/resources/js/sortable.js' + ); +} + +beforeEach(function () { + Schema::create('mg_tasks', function (Blueprint $t) { + $t->id(); + $t->string('title')->nullable(); + $t->string('owner_name')->nullable(); + $t->integer('sort_order')->nullable(); + }); + + MgTask::create(['title' => 'Ada', 'owner_name' => 'Amelia', 'sort_order' => 1]); +}); + +afterEach(fn () => Schema::dropIfExists('mg_tasks')); + +test('the morph guard names the one cell it protects instead of the whole table', function () { + // The regression, as source: a guard keyed on the focused element's TAG + // NAME matches the search box, the filter inputs and the per-page select as + // readily as an editable cell — and it was answered for the wrapper, whose + // subtree is the table. + expect(mgSource()) + ->toContain("active.closest('[data-record-key][data-column-name]')") + // Exactly that node, not "somewhere below it": a skip on the wrapper is + // a skip on everything. + ->toContain('if (cell && el === cell) skip();') + ->not->toContain("active.tagName === 'INPUT'") + ->not->toContain("active.tagName === 'TEXTAREA'") + ->not->toContain("active.tagName === 'SELECT'"); +}); + +test('a drag in progress still stops the morph outright', function () { + // The other half of the guard is untouched and must stay that way: a drag + // moves rows the server render knows nothing about, so the whole subtree + // has to be left alone until the drop lands. + expect(mgSource())->toMatch('/if \(this\.isDragging\) \{\s*skip\(\);\s*return;\s*\}/'); +}); + +test('re-initialisation is deferred while a cell is being edited, and coalesced otherwise', function () { + // setup() rebuilds the drag-handle cells, which drops focus — so not + // during an edit. And `morph.updated` fires for every patched node, so the + // rebuild is queued once per morph rather than once per node. + expect(mgSource()) + ->toContain('if (this.editingCell()) return;') + ->toContain('if (this._setupQueued) return;') + ->toMatch('/scheduleSetup\(\) \{/'); +}); + +test('the morph hooks are installed once for the page, not once per table', function () { + $source = mgSource(); + + // `Livewire.hook()` has no off switch, so a pair registered from init() + // stacks a fresh pair every time a controller initialises — a second + // reorderable table, a wire:navigate, a table inside a lazily loaded modal + // — and every stacked copy goes on running against a component that no + // longer exists. wire-table's record-actions guard and the fill handle's + // both solve this at module scope; this is the same shape. + expect($source) + ->toContain('let morphGuardsInstalled = false;') + ->toContain('if (morphGuardsInstalled || !window.Livewire) return;') + // Registered by wrapper element: Alpine calls destroy() with a merge + // proxy of the scope rather than the instance init() saw, so a + // controller cannot remove itself by identity — and keying by the + // element means a replacement takes over the entry instead of joining + // it, even if destroy() never runs at all. + ->toContain('controllers.set(this.$root, this);') + ->toContain('controllers.delete(this.$root);') + ->and(substr_count($source, 'Livewire.hook('))->toBe(2); + + // And none of them from init(), which is what stacked them. + preg_match('/\n init\(\) \{(.*?)\n \},/s', $source, $matches); + + expect($matches[1] ?? '')->not->toBeEmpty() + ->and($matches[1])->not->toContain('hook('); +}); + +test('the shipped bundle carries the narrowed guard', function () { + // Fails if the dist drifts from source (needs `npm run build:sortable-assets`). + expect(file_get_contents(WireSortableServiceProvider::ASSETS_PATH.'/wire-sortable.js')) + ->toContain('data-record-key') + ->toContain('data-column-name'); +}); + +test('an editable cell really does carry the attribute pair the guard looks for', function () { + // The cross-package half of the contract: the selector lives in + // wire-sortable, the markup in wire-table. If the cell stops rendering the + // pair, the guard silently protects nothing and an edit in flight is + // overwritten by the next poll tick — with no error anywhere. + $html = Livewire::test(MgHost::class)->html(); + + expect($html) + ->toContain('wire-sortable-wrapper') + ->toMatch('/data-record-key="[^"]+"\s+data-column-name="owner_name"/'); +}); + +test('the wrapper carrying the guards comes from reordering, not from the trait', function () { + // Scope of the bug, in markup: the wrapper — and with it the global morph + // hooks — is rendered for `reorderable()` OR `columnReorderable()`, so both + // were equally affected. `WithSortable` alone renders no wrapper and never + // was. + expect(Livewire::test(MgHost::class)->html()) + ->toContain('x-data="wireSortable(') + ->and(Livewire::test(MgPlainHost::class)->html()) + ->not->toContain('wire-sortable-wrapper'); +}); diff --git a/workbench/app/Livewire/Previews/SortablePreview.php b/workbench/app/Livewire/Previews/SortablePreview.php index e72e0663..3db703ca 100644 --- a/workbench/app/Livewire/Previews/SortablePreview.php +++ b/workbench/app/Livewire/Previews/SortablePreview.php @@ -9,6 +9,7 @@ use NyonCode\WireSortable\Concerns\WithSortable; use NyonCode\WireTable\Columns\BadgeColumn; use NyonCode\WireTable\Columns\TextColumn; +use NyonCode\WireTable\Columns\TextInputColumn; use NyonCode\WireTable\Concerns\WithTable; use NyonCode\WireTable\Table; use Workbench\App\Models\Task; @@ -28,7 +29,7 @@ public function mount(string $variant = 'overview'): void // Column order is persisted per user, so the reorder preview needs // someone logged in — without an id, reorderColumns() returns early and // the next render puts the columns straight back. - if ($variant === 'columns' && ! auth()->check()) { + if (in_array($variant, ['columns', 'morph'], true) && ! auth()->check()) { $user = User::query()->first(); if ($user !== null) { @@ -39,6 +40,10 @@ public function mount(string $variant = 'overview'): void public function table(Table $table): Table { + if ($this->variant === 'morph') { + return $this->morphTable($table); + } + $status = BadgeColumn::make('status') ->label('Status') ->colors([ @@ -101,6 +106,34 @@ public function table(Table $table): Table return $table; } + /** + * A reorderable table that still has to answer the ordinary controls. + * + * `columnReorderable()` alone, deliberately: it renders the sortable + * wrapper — and with it the drag controller's morph guards — without + * putting the table in row-reorder mode, which bypasses search by design + * (see WithSortable::interceptTableRecords). So what the search box does + * here is exactly what it does on any other table, and any difference is + * the wrapper's doing. + * + * The editable column is the other half: the guards exist to protect a + * cell mid-write from a morph, and narrowing them must not stop them + * doing that. + */ + private function morphTable(Table $table): Table + { + return $table + ->model(Task::class) + ->columnReorderable() + ->columns([ + TextColumn::make('title')->label('Task')->searchable(), + TextInputColumn::make('owner_name')->label('Owner'), + TextColumn::make('status')->label('Status'), + ]) + ->defaultSort('sort_order') + ->paginated(false); + } + public function render() { return view('livewire.previews.sortable-preview'); diff --git a/workbench/routes/web.php b/workbench/routes/web.php index 38dfa162..d51d9f9e 100644 --- a/workbench/routes/web.php +++ b/workbench/routes/web.php @@ -388,6 +388,7 @@ 'sortable-overview' => ['title' => 'Wire Sortable', 'subtitle' => 'Full reorderable task table preview.', 'component' => SortablePreview::class, 'variant' => 'overview'], 'sortable-detail' => ['title' => 'Wire Sortable Detail', 'subtitle' => 'Closer reorder-surface preview.', 'component' => SortablePreview::class, 'variant' => 'detail'], 'sortable-columns' => ['title' => 'Wire Sortable Columns', 'subtitle' => 'Drag a header to reorder columns, on a table that also has a selection column and row handles.', 'component' => SortablePreview::class, 'variant' => 'columns'], + 'sortable-morph' => ['title' => 'Wire Sortable Morph', 'subtitle' => 'A column-reorderable table with a search box and an editable cell — what the drag controller is allowed to keep a Livewire morph from doing.', 'component' => SortablePreview::class, 'variant' => 'morph'], 'gesture-lab' => ['title' => 'Gesture Lab', 'subtitle' => 'Every selection gesture, record action, the shortcut help and column reordering on one table, with a live state read-out.', 'component' => GestureLabPreview::class, 'variant' => 'lab'], 'gesture-lab-paged' => ['title' => 'Gesture Lab (paged)', 'subtitle' => 'The same lab over 20 rows a page — what "select all matching" needs to mean anything.', 'component' => GestureLabPreview::class, 'variant' => 'paged'], 'gesture-lab-click' => ['title' => 'Gesture Lab (single click)', 'subtitle' => 'A table whose only record action is a single click opening a modal.', 'component' => GestureLabPreview::class, 'variant' => 'click-only'], diff --git a/workbench/scripts/verify-sortable-morph.mjs b/workbench/scripts/verify-sortable-morph.mjs new file mode 100644 index 00000000..2d628bd2 --- /dev/null +++ b/workbench/scripts/verify-sortable-morph.mjs @@ -0,0 +1,249 @@ +import { openPage, checker, sleep } from './lib/cdp.mjs'; + +/* + * CDP driver for what the drag controller is allowed to keep a Livewire morph + * from doing. + * + * wireSortable registers two global morph hooks so a drag in progress, and a + * cell being typed into, survive a re-render. Both used to ask the wrong + * question — "is ANY input inside the table focused?" — of every node from the + * sortable wrapper down. `skip()` takes the whole subtree with it and + * `contains()` is inclusive, so the answer came back yes at the wrapper and the + * morph never entered the table at all. + * + * The search box is an input inside the table. Typing in it therefore silenced + * exactly the render it had just asked for: the server filtered correctly, sent + * the rows back, and the client threw them away. To the user the table simply + * stopped responding to search — no error, nothing in the console, the same + * "10 of 7097" as before. + * + * None of this is visible to Pest: the response is right on every render. The + * bug is only what the client does with it. + * + * The fixture (`sortable-morph`) is column-reorderable — enough to render the + * wrapper and its hooks — but NOT in row-reorder mode, which bypasses search on + * the server by design and would hide the very thing under test. It carries a + * search box and an editable column, the two halves of the guard. + * + * Usage (see .claude/skills/verify-preview/SKILL.md): + * vendor/bin/testbench serve --host=127.0.0.1 --port=8085 # in background + * node workbench/scripts/verify-sortable-morph.mjs + * + * Exit code 0 = all checks passed; 1 = a check failed; 2 = driver error. + */ + +const url = process.env.PREVIEW_URL ?? 'http://127.0.0.1:8085/previews/sortable-morph'; + +const { page, eval_, shot, shotDir, consoleErrors, badResponses, close } = await openPage({ + url, shotPrefix: 'sortable-morph', width: 1400, height: 1000, settle: 3500, +}); +const { check, finish } = checker(); + +const type = async (text) => { + for (const ch of text) { + await page('Input.dispatchKeyEvent', { type: 'keyDown', text: ch }); + await page('Input.dispatchKeyEvent', { type: 'keyUp' }); + await sleep(60); + } +}; + +try { + await eval_(` + window.root = document.querySelector('[x-data*="wireSortable"]'); + window.d = Alpine.$data(root); + window.cmp = Livewire.find(root.closest('[wire\\\\:id]').getAttribute('wire:id')); + + window.search = () => document.querySelector('[data-testid="table-search"]'); + window.rows = () => document.querySelectorAll('tbody tr[data-row-key]').length; + window.titles = () => [...document.querySelectorAll('tbody tr[data-row-key] [data-column="title"]')] + .map((el) => el.textContent.trim()); + window.cells = () => [...document.querySelectorAll('[data-record-key][data-column-name]')]; + window.cellInput = (i = 0) => cells()[i].querySelector('input'); + + // How far a morph actually gets inside the table, and how often the drag + // controller rebuilds itself while it happens. Both are counted from the + // real hooks, so a skip at the wrapper shows up as "the body was never + // visited" rather than as an absence of symptoms. + window.probe = { visited: 0, inBody: 0, setups: 0 }; + window.resetProbe = () => { probe.visited = 0; probe.inBody = 0; probe.setups = 0; }; + Livewire.hook('morph.updating', ({ el }) => { + if (! root.contains(el)) return; + probe.visited++; + if (el.closest && el.closest('tbody')) probe.inBody++; + }); + const realSetup = d.setup.bind(d); + d.setup = () => { probe.setups++; return realSetup(); }; + + // One morph, on demand, with nothing else moving. The promise is parked on + // window and not returned: awaiting it over CDP is what "Promise was + // collected" comes from, and the sleep after each call is the real wait. + window.morph = () => { resetProbe(); window._pending = cmp.$refresh(); return true; }; + true; + `); + + const baseline = await eval_('rows()'); + check('the fixture booted with the sortable wrapper, a search box and editable cells', + await eval_('!! root && !! d && !! search() && cells().length > 0'), + `${baseline} rows, ${await eval_('cells().length')} editable cells`); + check('the table is column-reorderable, not in row-reorder mode', + await eval_('!! d.columnSortableInstance && d.isReordering === false'), + `columnSortable=${await eval_('!! d.columnSortableInstance')} isReordering=${await eval_('d.isReordering')}`); + await shot('01-loaded'); + + // ── 1. search, with the search box still focused ───────────────────────── + // The regression, exactly as a user meets it: click the box, type, never + // click away. `wire:model.live.debounce` commits 300ms after the last + // keystroke, while focus is still in the input. + const needle = 'Publish'; + await eval_(`resetProbe(); search().focus()`); + await type(needle); + await sleep(1200); + + const filtered = await eval_('rows()'); + check('search filters while the search box still has focus', + filtered > 0 && filtered < baseline, + `${baseline} rows → ${filtered} rows for "${needle}"`); + check('the rows left are the matching ones', + (await eval_('JSON.stringify(titles())')).includes(needle), + await eval_('JSON.stringify(titles())')); + check('the search box kept focus and the typed term', + await eval_(`document.activeElement === search() && search().value === ${JSON.stringify(needle)}`), + `value=${JSON.stringify(await eval_('search().value'))} focused=${await eval_('document.activeElement === search()')}`); + check('the morph reached the table body instead of stopping at the wrapper', + await eval_('probe.inBody') > 0, + `${await eval_('probe.visited')} nodes visited, ${await eval_('probe.inBody')} of them in `); + check('the drag controller rebuilt itself once for that morph, not once per node', + await eval_('probe.setups') === 1, `${await eval_('probe.setups')} setup() calls`); + check('column dragging is still wired up after the morph', + await eval_('!! d.columnSortableInstance')); + await shot('02-searched'); + + // Clearing it puts the rows back — still without leaving the input. + await eval_(`resetProbe(); search().focus()`); + for (let i = 0; i < needle.length; i++) { + await page('Input.dispatchKeyEvent', { type: 'keyDown', windowsVirtualKeyCode: 8, nativeVirtualKeyCode: 8, key: 'Backspace', code: 'Backspace' }); + await page('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 8, nativeVirtualKeyCode: 8, key: 'Backspace', code: 'Backspace' }); + await sleep(60); + } + await sleep(1200); + check('clearing the search restores every row, focus still in the box', + await eval_('rows()') === baseline && await eval_('document.activeElement === search()'), + `${await eval_('rows()')} rows`); + await shot('03-cleared'); + + // ── 1b. a morph landing mid-word ───────────────────────────────────────── + // A poll tick, or someone else's broadcast, arriving inside the 300ms + // debounce window: the server still holds the previous term, so its render + // carries the OLD value for the box the user is typing into. That question + // could not even be asked on a reorderable table before — the global skip + // answered it by throwing the whole render away. + await eval_(`search().focus()`); + await type('Pub'); + await eval_(`cmp.$refresh()`); + await sleep(900); + check('a morph landing mid-word leaves the half-typed term alone', + await eval_('search().value') === 'Pub', + `value=${JSON.stringify(await eval_('search().value'))}`); + await sleep(1200); + check('and the term still filters once the debounce settles', + await eval_('rows()') < baseline, `${await eval_('rows()')} rows`); + + for (let i = 0; i < 3; i++) { + await page('Input.dispatchKeyEvent', { type: 'keyDown', windowsVirtualKeyCode: 8, nativeVirtualKeyCode: 8, key: 'Backspace', code: 'Backspace' }); + await page('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 8, nativeVirtualKeyCode: 8, key: 'Backspace', code: 'Backspace' }); + await sleep(60); + } + await sleep(1200); + check('and clears again', await eval_('rows()') === baseline, `${await eval_('rows()')} rows`); + + // ── 2. a cell mid-write still survives a morph ─────────────────────────── + // The half the guards are actually for. Type into an editable cell without + // committing it, then make an unrelated re-render land on top. + const original = await eval_('cellInput(0).value'); + await eval_(`cellInput(0).focus()`); + await type('ZZ'); + const typed = await eval_('cellInput(0).value'); + check('the editable cell took the keystrokes', typed !== original, `${JSON.stringify(original)} → ${JSON.stringify(typed)}`); + + await eval_(`morph()`); + await sleep(800); + check('the half-typed cell survives a morph that lands on top of it', + await eval_('cellInput(0).value') === typed, + `${JSON.stringify(typed)} → ${JSON.stringify(await eval_('cellInput(0).value'))}`); + check('focus stayed in the cell being edited', + await eval_('document.activeElement === cellInput(0)')); + check('the rest of the table morphed anyway — only that one cell was skipped', + await eval_('probe.inBody') > 1, + `${await eval_('probe.visited')} nodes visited, ${await eval_('probe.inBody')} in `); + check('no re-init while a cell is being edited (it would drop the drag handles under focus)', + await eval_('probe.setups') === 0, `${await eval_('probe.setups')} setup() calls`); + await shot('04-editing'); + + // Escape reverts the cell, so the run leaves the fixture's data as it found + // it — the cell saves on blur otherwise, and the next run would start from + // whatever this one typed. + await page('Input.dispatchKeyEvent', { type: 'keyDown', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27, key: 'Escape', code: 'Escape' }); + await page('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27, key: 'Escape', code: 'Escape' }); + await eval_(`document.activeElement.blur()`); + await sleep(700); + check('escape put the cell back, so nothing was written', + await eval_('cellInput(0).value') === original, + `${JSON.stringify(await eval_('cellInput(0).value'))} (was ${JSON.stringify(original)})`); + + // ── 3. the guard that stays: a drag in progress still blocks the morph ─── + await eval_(`d.isDragging = true; morph()`); + await sleep(600); + check('a drag in progress still stops the morph at the wrapper', + await eval_('probe.inBody') === 0, + `${await eval_('probe.visited')} nodes visited, ${await eval_('probe.inBody')} in `); + await eval_(`d.isDragging = false`); + + await eval_(`morph()`); + await sleep(600); + check('and the next morph after the drag ends goes all the way through', + await eval_('probe.inBody') > 0, + `${await eval_('probe.visited')} nodes visited, ${await eval_('probe.inBody')} in `); + await shot('05-after-drag-guard'); + + // ── 4. a re-initialised controller replaces the old one, it does not stack ─ + // `Livewire.hook()` has no off switch, so hooks registered from init() pile + // up — one set per wire:navigate, per lazily loaded table, per second table + // on the page — and each stacked copy goes on running against a component + // that no longer exists. Tearing the Alpine tree down and building it again + // is what a navigation does to this wrapper, in one step. + await eval_(` + window.stale = d; + Alpine.destroyTree(root); + Alpine.initTree(root); + window.d = Alpine.$data(root); + true; + `); + await sleep(800); + check('re-initialising the wrapper yields a new controller', + await eval_('d !== stale') && await eval_('!! d.columnSortableInstance')); + + // The destroyed controller still points at the same element, so a stale + // registration would let its isDragging keep blocking every morph the page + // asks for — the original bug's failure mode, arrived at from the other side. + await eval_(`stale.isDragging = true; morph()`); + await sleep(800); + check('a destroyed controller no longer speaks for the table', + await eval_('probe.inBody') > 0, + `${await eval_('probe.visited')} nodes visited, ${await eval_('probe.inBody')} in `); + await eval_(`stale.isDragging = false`); + + // …and the live one still does. + await eval_(`d.isDragging = true; morph()`); + await sleep(800); + check('the controller that replaced it guards the morph instead', + await eval_('probe.inBody') === 0, + `${await eval_('probe.visited')} nodes visited, ${await eval_('probe.inBody')} in `); + await eval_(`d.isDragging = false`); + await shot('06-after-reinit'); +} catch (err) { + console.error('DRIVER ERROR:', err.message); + process.exitCode = 2; +} finally { + finish({ consoleErrors, badResponses, shotDir }); + await close(); +} From 779ff83f930c1c9d50fb71f2bf957aa1447eb0cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nykl=C3=AD=C4=8Dek?= <60318239+ONyklicek@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:15:40 +0200 Subject: [PATCH 02/30] Give the three editors one vocabulary, and TipTap a starting document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editors titled their toolbars with bare __('Bold') keys, which only an app-level translation file can ever answer. A Czech app therefore shipped a fully translated form with an English editor bolted into the middle of it, and the two strings that are read inside the JS bundle — the link and image prompt() titles — could not be translated at all. All of it now resolves from wire-forms::fields.editor.*, en and cs, and the group is named editor rather than tiptap because RichEditor and MarkdownEditor title their buttons from the very same keys: one vocabulary for all three, so they read alike in every locale and a reworded button is reworded once. The prompt titles are resolved in PHP and handed to the editor through its Alpine config, so a locale change reaches strings that live inside the bundle. Headings read as Heading 2 / Nadpis 2; the glyph on the button stays H1/H2/H3 in every locale, being a symbol rather than a word. RichEditor's link prompt moved to @js(), which hex-escapes both quote characters. The old prompt('{{ __('Enter URL') }}') renders an apostrophe as ', which decodes back to a quote and closes the JS string — and with it the x-data attribute around it — so any wording containing one would have killed the field. A starting document is the canonical ->default() and not a second editor-only method. The form runtime already seeds it into the state bag; the field now also hands it to the editor, which applies it when the bound value is empty and pushes the parsed document back into Livewire, so a host that never seeded — a null column, a hand-bound property — still opens on the template, and saving an untouched form stores it rather than nothing. The default is markup, so it arrives formatted. Under outputJson() it may be a JSON document string or the same HTML, which is where the old code dropped it: a non-JSON value was parsed with a catch returning {} and became an empty editor. Re-opening a document the user deliberately cleared does not bring the default back — an emptied editor stores

, not ''. Two bugs found while switching MarkdownEditor over, both from its Alpine component being written inline as an x-data attribute, where the HTML parser reads the code before JavaScript does: - A raw double quote ends the attribute wherever it appears, so the regex literal /\"/g truncated the component mid-function. Alpine got an expression ending in .replace(/\ and threw "Invalid regular expression: missing /" — as a warning, after which nothing worked: no Write/Preview switch, no preview, no entangle, no toolbar insertion. The page source still looked complete, which is why no test saw it. - An entity is decoded, so '&' written once arrived as '&' and the preview's sanitiser read replace(& with &). The HTML neutralisation the comment above it promised was a no-op on all four characters, and raw markup reached x-html unescaped. Every quote in that expression is an entity now and every replacement is written twice over; the rendered output is byte-identical, and typing shows as text rather than making a request. Verified where each thing actually lives. Pest covers the vocabulary, the en/cs parity, the default reaching both the state bag and the editor config, and — for the x-data pair — the attribute as the parser decodes it rather than as the source reads, since the source looked right in both cases. verify-tiptap-split.mjs (14/14) now also reads the seeded default out of Livewire's state against a new /previews/field-tiptap-default, and a new verify-markdown-editor.mjs (7/7) drives the two older editors, which had no browser coverage at all — which is how this survived. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 +- docs/cs/forms/fields/markdown-editor.md | 18 ++ docs/cs/forms/fields/rich-editor.md | 16 ++ docs/cs/forms/fields/tiptap-editor.md | 68 ++++++- docs/forms/fields/markdown-editor.md | 18 ++ docs/forms/fields/rich-editor.md | 16 ++ docs/forms/fields/tiptap-editor.md | 67 ++++++- .../docs/forms/fields/markdown-editor.md | 18 ++ .../boost/docs/forms/fields/rich-editor.md | 16 ++ .../boost/docs/forms/fields/tiptap-editor.md | 67 ++++++- .../{chunk-72BVZGAJ.js => chunk-UWS4WPU7.js} | 32 +-- .../forms/dist/tiptap/tiptap-editor-addons.js | 2 +- packages/forms/dist/tiptap/tiptap-editor.js | 4 +- packages/forms/resources/js/tiptap-editor.js | 59 ++++-- packages/forms/resources/lang/cs/fields.php | 34 ++++ packages/forms/resources/lang/en/fields.php | 34 ++++ .../components/markdown-editor.blade.php | 61 ++++-- .../views/components/rich-editor.blade.php | 34 ++-- .../views/components/tiptap-editor.blade.php | 49 ++--- .../forms/src/Components/TiptapEditor.php | 37 ++++ .../tests/Feature/EditorLocalizationTest.php | 185 ++++++++++++++++++ .../tests/Feature/MarkdownEditorXDataTest.php | 88 +++++++++ .../Feature/TiptapDefaultContentTest.php | 63 ++++++ .../Unit/Components/MoreFieldTypesTest.php | 4 +- .../app/Livewire/Previews/FieldPreview.php | 23 ++- workbench/routes/web.php | 3 + workbench/scripts/verify-markdown-editor.mjs | 181 +++++++++++++++++ workbench/scripts/verify-tiptap-split.mjs | 23 +++ 28 files changed, 1119 insertions(+), 107 deletions(-) rename packages/forms/dist/tiptap/{chunk-72BVZGAJ.js => chunk-UWS4WPU7.js} (82%) create mode 100644 packages/forms/tests/Feature/EditorLocalizationTest.php create mode 100644 packages/forms/tests/Feature/MarkdownEditorXDataTest.php create mode 100644 packages/forms/tests/Feature/TiptapDefaultContentTest.php create mode 100644 workbench/scripts/verify-markdown-editor.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f122bc..72368fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,17 @@ All notable changes to the Wire ecosystem will be documented in this file. ## [1.16.1] +### Added +- **The TipTap editor speaks Czech, and opens on a pre-formatted document — `->default()`.** Its toolbar tooltips were bare `__('Bold')` keys, which only an *app-level* translation file could ever answer: a Czech app shipped a fully translated form with an English editor bolted into the middle of it, and the two strings that live inside the JS bundle — the link and image `prompt()` titles — could not be translated at all. All of it now resolves from the package's own vocabulary (`wire-forms::fields.editor.*`, `en` + `cs`), including the prompt titles, which are read in PHP and handed to the editor through its Alpine config rather than being hardcoded in the bundle; the group is named `editor` rather than `tiptap` because **RichEditor and MarkdownEditor title their toolbars from the very same keys** — one vocabulary for all three editors, so they read alike in every locale and a reworded button is reworded once. RichEditor's link prompt moved from `prompt('{{ __('Enter URL') }}')` to `@js()`, which hex-escapes both quote characters: the old form rendered an apostrophe as `'`, and a locale whose wording contains one would have closed the JS string and, with it, the `x-data` attribute around it. Headings read as *Heading 2* / *Nadpis 2* rather than `H2` — the glyph on the button stays `H1`/`H2`/`H3` in every locale, since those are symbols, not words. Separately, a starting template is now the canonical `->default()` and **not** a second editor-only method: the form runtime already seeds it into the state bag, and the field additionally hands it to the editor, which applies it when the bound value is empty and pushes the parsed document back into Livewire — so a host that never seeded (a `null` column, a hand-bound property) still opens on the template, and saving an untouched form stores it rather than nothing. The default is markup, so `'

Zápis z porady

Nějaký text

'` arrives formatted; under `->outputJson()` it may be a JSON document string *or* the same HTML, which is where the old code dropped it — a non-JSON value was parsed with a `catch { return {} }` and became an empty editor. Re-opening a document the user deliberately cleared does not bring the default back: an emptied editor stores `

`, not `''`. Browser-verified by `workbench/scripts/verify-tiptap-split.mjs` (14/14) against a new `/previews/field-tiptap-default`, which reads the seeded document out of Livewire's state, not just off the screen. See `docs/forms/fields/tiptap-editor.md`. + ### Fixed +- **The MarkdownEditor was dead in the browser, and its preview sanitiser was a no-op.** Its Alpine component is written inline as an `x-data` attribute, which means the HTML parser reads that code before JavaScript ever does — and both halves of the field were broken by forgetting it. A **raw** double quote ends an attribute wherever it appears, whatever the JS around it means by it, so the regex literal `/\"/g` truncated the component mid-function: Alpine was handed an expression ending in `.replace(/\` and threw *Invalid regular expression: missing /* — a **warning**, not an error, after which nothing worked. No Write/Preview switch, no preview, no `entangle`, no toolbar insertion; the page source still looked complete, and every PHP test passed, because the markup that went *in* was fine. The mirror-image mistake sat three characters later: an entity is *decoded*, so `'&'` written once arrived as `'&'` and the preview's first line read `replace(& with &)` — the HTML neutralisation that the comment above it promised was a no-op on all four characters, and raw markup went to `x-html` unescaped. Every quote in the expression is now an entity and every replacement is written twice over; the rendered output is byte-identical, and typing `` into the editor now shows as text rather than making a request. Both are asserted against the attribute **as the parser decodes it** (`MarkdownEditorXDataTest`) rather than against the source, since the source looked right in each case, and browser-verified by a new `workbench/scripts/verify-markdown-editor.mjs` (7/7) against new `/previews/field-markdown-editor` and `/previews/field-rich-editor` previews — the two editors had no browser coverage at all, which is why this survived. Found while switching the three editors onto one translation vocabulary. - **A reorderable table stopped responding to its own search box.** With `reorderable()` or `columnReorderable()` on, typing in the search field did nothing: the server filtered correctly and sent the rows back, and the client threw the whole response away — no error, nothing in the console, the same row count as before. The drag controller registers two Livewire morph hooks so a drag in progress, and a cell being typed into, survive a re-render, and both asked the wrong question: *is any input inside the table focused?* — of every node from the sortable wrapper down. `skip()` takes the whole subtree with it and `contains()` is inclusive, so the answer came back yes at the wrapper itself and the morph never entered the table. The search box is an input inside the table, which made it the one control guaranteed to silence the render it had just asked for; a filter input or the per-page select did the same. Both guards now name what they protect: the cell being edited, identified by the `[data-record-key][data-column-name]` pair the editable columns render — the selector `wireTableLive` already reads in `busy()` — and skipped only when the morph is at that exact node, so its siblings and the rest of the table reconcile normally. The drag guard is unchanged: a drag still stops the morph outright, because it has moved rows the server render knows nothing about. While at it, the post-morph re-init is queued once per morph instead of once per patched node — `morph.updated` fires for every element Livewire touches, so a table of any size was tearing down and rebuilding both SortableJS instances a hundred times a render. Both hooks are now also installed **once per document** instead of once per controller: `Livewire.hook()` has no off switch, so a pair registered from `init()` stacked another pair on every re-init — a second reorderable table, a `wire:navigate`, a table inside a lazily loaded modal — and every stacked copy went on answering for a component that no longer existed, a destroyed controller's `isDragging` still able to block every morph on the page. The live controllers sit in a module-level map keyed by their wrapper element, which is what `wire-table`'s record-actions guard and the fill handle already do; keyed by the element because Alpine calls `destroy()` with a merge proxy of the scope rather than the instance `init()` saw, so removing by identity removes nothing. Browser-verified by `workbench/scripts/verify-sortable-morph.mjs` (25/25) against a new `/previews/sortable-morph`, which fails on the old bundle exactly as reported: 6 rows in, 6 rows out, the morph stopping one node inside the wrapper. ## [1.16.0] ### Added -- **Search understands more than one substring — `Table::search()`.** The box matched whatever was typed as a single `LIKE '%term%'` across every searchable column, which meant `Ada Lovelace` could not find the row whose first name is in one column and surname in another, and a number could only ever be searched *for*, never compared. Three capabilities are now opt-in per table, through a fluent `SearchConfig`: `tokenize()` splits on spaces and ANDs the words — each word still ORs across all columns, which is exactly what makes a name spanning two columns match — with double quotes keeping a phrase together and never being read as an operator; `ranges()` reads `>100`, `>=100`, `<10`, `<=10`, `=42`, `10..20`, `10..`, `..20` and the same over dates; `wildcards()` lets `*` and `?` stand for runs and single characters. **Everything is off by default**, so an unconfigured table matches byte-for-byte what it always did — the whole term, one group, one substring. A typed date is read at the granularity it was written (`2026-01-31` is that day, `2026-01` that month, `2026` that year), so `<=2026-01-31` still includes a row stamped 23:30 on the 31st, which is the off-by-a-day this kind of feature usually ships with. A comparison is only ever asked of a column that can answer it — the value type comes from the model's casts, or from the new `Column::searchAs('numeric'|'date')` where the casts cannot speak for the column — and a comparison **no** column can answer (`>100` on a table of names) is searched as the literal text that was typed rather than contributing an empty WHERE group that matches every row. The engine lives in `wire-core` (`Core\Query\Search\`) as a parser producing tokens, a compiler turning a token plus a clause into SQL, and the three driver strategies reduced to the one thing that genuinely differs between engines: `LIKE` versus `ILIKE`. Comparisons are plain portable SQL and are therefore built once rather than three times over. `searchAs('code')` covers the structured reference — `8866 01`, `8866 02` — where the series is shared and the tail is zero-padded: typing `8866 01..08` yields one `BETWEEN '8866 01' AND '8866 08'` rather than a `LIKE` per number in the range. The space inside such a code is also what splits the term, so the range arrives separated from its series; rather than letting one reading win at parse time (`8866 01..08` and `praha 10..20` are the same shape and cannot be told apart syntactically), the range **carries the word typed before it** and each column takes the reading it can answer — a code column completes both bounds with the series, a numeric column ignores it and compares `1..8`. Comparing as text only orders correctly while the width is constant, which is the assertion `searchAs('code')` makes, so the number must be typed as it is stored; a range crossing a width boundary is completed rather than refused (`8866 50..100` reads as `050..100`, since a hundredth member can only exist in a three-digit series). See `docs/table/overview.md` § Search syntax. +- **Search understands more than one substring — `Table::search()`.** The box matched whatever was typed as a single `LIKE '%term%'` across every searchable column, which meant `Ada Lovelace` could not find the row whose first name is in one column and surname in another, and a number could only ever be searched *for*, never compared. Three capabilities are now opt-in per table, through a fluent `SearchConfig`: `tokenize()` splits on spaces and ANDs the words — each word still ORs across all columns, which is exactly what makes a name spanning two columns match — with double quotes keeping a phrase together and never being read as an operator; `ranges()` reads `>100`, `>=100`, `<10`, `<=10`, `=42`, `10..20`, `10..`, `..20` and the same over dates; `wildcards()` lets `*` and `?` stand for runs and single characters. **Everything is off by default**, so an unconfigured table matches byte-for-byte what it always did — the whole term, one group, one substring. A typed date is read at the granularity it was written (`2026-01-31` is that day, `2026-01` that month, `2026` that year), so `<=2026-01-31` still includes a row stamped 23:30 on the 31st, which is the off-by-a-day this kind of feature usually ships with. A comparison is only ever asked of a column that can answer it — the value type comes from the model's casts, or from the new `Column::searchAs('numeric'|'date')` where the casts cannot speak for the column — and a comparison **no** column can answer (`>100` on a table of names) is searched as the literal text that was typed rather than contributing an empty WHERE group that matches every row. The engine lives in `wire-core` (`Core\Query\Search\`) as a parser producing tokens, a compiler turning a token plus a clause into SQL, and the three driver strategies reduced to the one thing that genuinely differs between engines: `LIKE` versus `ILIKE`. Comparisons are plain portable SQL and are therefore built once rather than three times over. `searchAs('code')` covers the structured reference — `8866 01`, `8866 02` — where the series is shared and the tail is zero-padded: typing `8866 01..08` yields one `BETWEEN '8866 01' AND '8866 08'` rather than a `LIKE` per number in the range. The space inside such a code is also what splits the term, so the range arrives separated from its series; rather than letting one reading win at parse time (`8866 01..08` and `praha 10..20` are the same shape and cannot be told apart syntactically), the range **carries the word typed before it** and each column takes the reading it can answer — a code column completes both bounds with the series, a numeric column ignores it and compares `1..8`. Comparing as text only orders correctly while the width is constant, which is the assertion `searchAs('code')` makes, so the number must be typed as it is stored; a range crossing a width boundary is completed rather than refused (`8866 50..100` reads as `050..100`, since a hundredth member can only exist in a three-digit series). Because the declaration only says what a column *can* answer and switches nothing on, a searchable column declaring a type while the table's search does not read ranges is now refused when the table renders, naming the `->search(...)` call it is missing — that configuration could otherwise only be discovered as an empty table, since `8866 01..08` was looked for as literal text. See `docs/table/overview.md` § Search syntax. - **Four new columns, closing the gap where the table could not show what an infolist entry already could.** `ColorColumn` renders a stored CSS color as a swatch plus its value (`swatchOnly()` for a narrow column), the table-side counterpart of `ColorEntry`. `CheckboxColumn` is an inline checkbox writing a boolean straight to the record — the same optimistic write path, the same server-side `canEdit()` guard and the same sync node as `ToggleColumn`, for tables too dense for a switch track. `RatingColumn` draws a numeric score as stars (`max()`, `allowHalf()`, `showValue()`), the read-only half of the `Rating` field's vocabulary. `TagsColumn` renders a multi-value state — array, JSON cast, `Arrayable` relation collection, or a `separator()`-split string — as chips, with `limitList()` collapsing the overflow into a "+N" chip. None of them re-encodes a palette: the tag chip is the *same* `RendersBadgeSurface` chrome as `BadgeColumn` and takes the same `colors()` map, and rating/checkbox colors resolve through the canonical Foundation owners. The three state-driven ones (`ColorColumn`, `RatingColumn`, `TagsColumn`) memoise their view render by its data, so a page of rows sharing a color, a score or a tag set costs one render each rather than one per row. `ToggleColumn` and `CheckboxColumn` now share `CanEditBooleanCell`, which owns the server-side disabled guard — the point being that a *new* boolean cell cannot ship without it. Browser-verified over CDP by `workbench/scripts/verify-column-surfaces.mjs` (20/20) against a new `/previews/table-column-surfaces`: the swatch colors as the browser actually parsed them, the seeded row whose stored value is `red; background-image: url(…)` drawing no background and issuing no request for it, a half star clipped only where halves are allowed, the `+N` overflow chip, and a checkbox cell committing through Livewire and surviving a fresh GET. See `docs/table/columns/`. - **`TrashedFilter` — soft deletes were not covered by any filter at all.** Unlike every other filter it constrains no column: it decides which global scope applies, mapping to `withTrashed()` / `onlyTrashed()`. Three states of which only two are options — "without deleted" is the placeholder, i.e. clearing the filter — rendered through the same select surface as `SelectFilter`, so an open soft-delete filter looks like any other. It `bypassesPlanner()`, since a scope change is not a column/operator/value definition. A model without `SoftDeletes` now fails with a `TableConfigurationException` naming both the filter and the model, rather than as an undefined `onlyTrashed()` deep inside the query builder — and the check runs only when the filter is *active*, so a cleared filter never inspects the model. It **extends `SelectFilter`** rather than `Filter`: the shared select panel calls `isSearchable()` on whatever it is handed, so the first version rendered a 500 on any table that used it — a failure every unit test missed, because they only asked the filter for its view's *name*. Browser-verified by `workbench/scripts/verify-trashed-filter.mjs` (14/14) against a new `/previews/table-trashed-filter`, which counts the rows that actually come back: 4 live, 2 with `only`, 6 with `with`, back to 4 when cleared. See `docs/table/filters/trashed.md`. - **`CheckboxList::segmented()` / `::buttons()` — the multiple-choice half of the toggle-button vocabulary.** `Radio` has had `segmented()` and `buttons()` for a while; picking *several* options in that shape had no equivalent, so a multi-select of three short values was a column of checkboxes. Rather than adding a parallel field, the shared part of Radio's API — the variants, per-option `icons()` and `colors()`, `inline()`, and the size/color resolvers — moved into `HasChoiceVariants`, which both fields now use: one vocabulary, one chrome, and a single-choice and multi-choice control that look alike. In these variants the field shows the options alone; search, bulk toggle, grouping and columns are list chrome and do not apply. Radio's own `cards` variant stays with Radio. Browser-verified by `workbench/scripts/verify-choice-variants.mjs` (15/15): the peer-checked pill actually paints, and — the part markup cannot show — a second click *adds* to the selection rather than replacing it. See `docs/forms/fields/checkbox-list.md`. diff --git a/docs/cs/forms/fields/markdown-editor.md b/docs/cs/forms/fields/markdown-editor.md index 016d4637..ae4e1271 100644 --- a/docs/cs/forms/fields/markdown-editor.md +++ b/docs/cs/forms/fields/markdown-editor.md @@ -59,6 +59,24 @@ Toolbar poskytuje klávesnicí přístupná tlačítka pro: Vestavěný náhled zvládá: nadpisy (`#`, `##`, `###`), bold/italic/strikethrough, inline kód, odkazy, blockquoty a neseřazené/seřazené seznamy. Pro plné GFM vykreslení uložený Markdown post-processujte na straně serveru knihovnou jako [CommonMark](https://commonmark.thephpleague.com/). +Náhled běží v prohlížeči a zapisuje se přes `x-html`, takže syrové HTML v Markdownu se **escapuje, nevykresluje**: `` se zobrazí jako text. URL odkazů jsou navíc omezené na `http(s):`, `mailto:`, `#` a cesty od kořene — cokoli jiného se změní na `#`, takže přes náhled nelze podstrčit `javascript:` odkaz. + +## Lokalizace + +Tooltipy toolbaru i popisky záložek Psát/Náhled pocházejí ze sdílené slovní +zásoby editorů `wire-forms::fields.editor.*` — ze stejných klíčů, jaké používají +[TiptapEditor](tiptap-editor.md#lokalizace) a +[RichEditor](rich-editor.md#lokalizace), takže všechny tři editory zní v každém +jazyce stejně. Angličtina (`en`) a čeština (`cs`) jsou součástí balíčku; česká +aplikace zobrazí *Tučné*, *Kód v textu* a záložky *Psát* / *Náhled*. + +Formulaci změníte (nebo přidáte další jazyk) publikováním překladů a úpravou +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + ## Metody | Metoda | Typ | Popis | diff --git a/docs/cs/forms/fields/rich-editor.md b/docs/cs/forms/fields/rich-editor.md index 0d2b6bde..b7d01f10 100644 --- a/docs/cs/forms/fields/rich-editor.md +++ b/docs/cs/forms/fields/rich-editor.md @@ -61,6 +61,22 @@ RichEditor::make('summary') ->maxLength(500) ``` +## Lokalizace + +Tooltipy toolbaru i prompt pro odkaz pocházejí ze sdílené slovní zásoby editorů +`wire-forms::fields.editor.*` — ze stejných klíčů, jaké používají +[TiptapEditor](tiptap-editor.md#lokalizace) a +[MarkdownEditor](markdown-editor.md#lokalizace), takže všechny tři editory zní +v každém jazyce stejně. Angličtina (`en`) a čeština (`cs`) jsou součástí balíčku; +česká aplikace zobrazí *Tučné*, *Číslovaný seznam*, *Nadpis 2* a prompt *URL odkazu*. + +Formulaci změníte (nebo přidáte další jazyk) publikováním překladů a úpravou +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + ## Metody | Metoda | Typ | Popis | diff --git a/docs/cs/forms/fields/tiptap-editor.md b/docs/cs/forms/fields/tiptap-editor.md index a9879bcf..8f8dac68 100644 --- a/docs/cs/forms/fields/tiptap-editor.md +++ b/docs/cs/forms/fields/tiptap-editor.md @@ -22,12 +22,16 @@ zapnutí tabulek nikdy neposílá druhou kopii jádra editoru. Script tagy `@assets`; registrují Alpine komponentu `tiptapEditor`, na kterou pohled spoléhá (Alpine se dodává s Livewire). -> **Publikování assetu (volitelné).** Pokud dáváte přednost servírování souborů přes -> vlastní asset pipeline/CDN, publikujte je pomocí: +> **Publikování assetu (volitelné).** Pokud má soubory servírovat váš webserver +> místo routy balíčku, publikujte je pomocí: > ```bash -> php artisan vendor:publish --tag=wire-forms::assets +> php artisan vendor:publish --tag=laravel-assets --force > ``` -> To zkopíruje bundly do `public/vendor/wire-forms/`. +> To zkopíruje bundly do `public/vendor/wire-forms/` — celého stacku, nejen tohoto +> balíčku — a editor od té chvíle emituje tyhle cesty včetně cache-busteru. Publish +> zrcadlí `dist/` doslova, takže si entry pointy dál resolvují sdílený chunk relativně +> vůči `vendor/wire-forms/tiptap/`. Viz +> [Začínáme → JavaScriptové assety](../../getting-started.md#javascriptove-assety). > **Přispěvatelé.** Bundly se generují z > `packages/forms/resources/js/tiptap-editor.js` a `tiptap-editor-addons.js` a @@ -45,6 +49,34 @@ zapnutí tabulek nikdy neposílá druhou kopii jádra editoru. Script tagy TiptapEditor::make('content') ``` +## Výchozí obsah + +Editor se otevře nad hodnotou z `->default()` — kanonického výchozího nastavení, +které má každá komponenta; žádná metoda navíc jen pro editor. Je to **markup, ne +holý text**, takže šablona přichází předformátovaná: + +```php +TiptapEditor::make('minutes') + ->default('

Zápis z porady

Nějaký text.

  • První bod
') +``` + +Jak se to vyhodnotí, v tomto pořadí: + +1. **Runtime formuláře hodnotu naseeduje.** `fill()` (a stejně tak výchozí stav + modalové akce) zapíše `->default()` do state bagu pro každý klíč, který volající + nedodal, takže editor se prostě otevře nad hodnotou, která už tam je. +2. **Editor ji naseeduje, když to hostitel neudělal** — `null` sloupec, ručně + navázaná property — výchozí obsah dosadí vždy, když je navázaná hodnota + prázdná, a rozparsovaný dokument pošle zpět do Livewire, takže uložení + formuláře, kterého se uživatel ani nedotkl, uloží šablonu, a ne nic. +3. **Vyprázdněný editor není prázdný.** Smazání obsahu uloží `

`, takže + znovuotevření dokumentu, který uživatel záměrně vyčistil, výchozí obsah + *nevrátí*. U editačního formuláře, kde je sloupec skutečně `null`, přidejte + `->defaultOnNull()`, aby default doplnil hodnotu i na straně serveru. + +Při `->outputJson()` může být výchozí hodnotou TipTap JSON dokument jako řetězec, +nebo totéž HTML — HTML se tak jako tak rozparsuje na dokument a uloží jako JSON. + ## Vlastní toolbar ```php @@ -122,6 +154,32 @@ TiptapEditor::make('content') ->disabled(fn () => ! $this->canEdit) ``` +## Lokalizace + +Editor si nenese vlastní angličtinu. Tooltipy toolbaru, popisky nadpisů i +prohlížečové prompty, které otevírá tlačítko odkazu a obrázku, se všechny +překládají z `wire-forms::fields.editor.*`, takže pole respektuje +`app()->getLocale()`. Angličtina (`en`) a čeština (`cs`) jsou součástí balíčku — +česká aplikace zobrazí *Tučné*, *Odrážkový seznam*, *Nadpis 2* a prompt +*URL odkazu*. + +Titulky promptů se vyhodnocují v PHP a předávají se do Alpine konfigurace +editoru — proto se změna jazyka propíše i do řetězců, které žijí uvnitř JS bundlu. + +[RichEditor](rich-editor.md#lokalizace) a +[MarkdownEditor](markdown-editor.md#lokalizace) popisují své toolbary z týchž +klíčů, takže všechny tři editory zní v každém jazyce stejně. + +Formulaci změníte (nebo přidáte další jazyk) publikováním překladů a úpravou +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + +Popisky tlačítek zůstávají `H1` / `H2` / `H3` ve všech jazycích — to jsou +symboly, ne slova; překládá se tooltip. + ## Dostupná toolbarová tlačítka | Klíč | Popis | @@ -167,6 +225,8 @@ TiptapEditor::make('content') | `toolbarButtons(array)` | array | Přepsat seznam toolbarových tlačítek | | `disableToolbarButtons(array)` | array | Odstranit konkrétní tlačítka | | `disableAllToolbarButtons()` | — | Skrýt toolbar úplně | +| `default(string\|Closure)` | string | Předformátovaný dokument, nad kterým se prázdný editor otevře | +| `defaultOnNull()` | — | Nechat `default()` doplnit i existující `null` při fill | | `outputHtml()` | — | Uložit obsah jako HTML (výchozí) | | `outputJson()` | — | Uložit obsah jako TipTap JSON řetězec | | `withImages(bool)` | bool | Zapnout rozšíření obrázků + tlačítko | diff --git a/docs/forms/fields/markdown-editor.md b/docs/forms/fields/markdown-editor.md index 7f9cd623..6fce3a2c 100644 --- a/docs/forms/fields/markdown-editor.md +++ b/docs/forms/fields/markdown-editor.md @@ -59,6 +59,24 @@ The toolbar provides keyboard-accessible buttons for: The built-in preview handles: headings (`#`, `##`, `###`), bold/italic/strikethrough, inline code, links, blockquotes, and unordered/ordered lists. For full GFM rendering, post-process the stored Markdown on the server side using a library like [CommonMark](https://commonmark.thephpleague.com/). +The preview runs in the browser and writes through `x-html`, so raw HTML in the Markdown is **escaped, not rendered**: `` shows as text. Link URLs are additionally restricted to `http(s):`, `mailto:`, `#` and root-relative paths — anything else becomes `#`, so a `javascript:` link cannot be planted through the preview. + +## Localization + +Toolbar tooltips and the Write/Preview tab labels come from the shared editor +vocabulary `wire-forms::fields.editor.*` — the same keys +[TiptapEditor](tiptap-editor.md#localization) and +[RichEditor](rich-editor.md#localization) use, so the three editors read alike in +every locale. English (`en`) and Czech (`cs`) ship with the package; a Czech app +shows *Tučné*, *Kód v textu*, and the tabs *Psát* / *Náhled*. + +Reword a string, or add a locale, by publishing the translations and editing +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + ## Methods | Method | Type | Description | diff --git a/docs/forms/fields/rich-editor.md b/docs/forms/fields/rich-editor.md index 47e19810..f15df440 100644 --- a/docs/forms/fields/rich-editor.md +++ b/docs/forms/fields/rich-editor.md @@ -61,6 +61,22 @@ RichEditor::make('summary') ->maxLength(500) ``` +## Localization + +Toolbar tooltips and the link prompt come from the shared editor vocabulary +`wire-forms::fields.editor.*` — the same keys +[TiptapEditor](tiptap-editor.md#localization) and +[MarkdownEditor](markdown-editor.md#localization) use, so the three editors read +alike in every locale. English (`en`) and Czech (`cs`) ship with the package; a +Czech app shows *Tučné*, *Číslovaný seznam*, *Nadpis 2*, and prompts *URL odkazu*. + +Reword a string, or add a locale, by publishing the translations and editing +`lang/vendor/wire-forms/{locale}/fields.php`: + +```bash +php artisan vendor:publish --tag=wire-forms::translations +``` + ## Methods | Method | Type | Description | diff --git a/docs/forms/fields/tiptap-editor.md b/docs/forms/fields/tiptap-editor.md index 91a2d73f..96ca3be1 100644 --- a/docs/forms/fields/tiptap-editor.md +++ b/docs/forms/fields/tiptap-editor.md @@ -22,12 +22,16 @@ The ` +``` + +Nic se nespouští, nic nenastavuje. Kopírování je inkrementální — soubor, který už +je na místě a je aktuální, se nechá být — takže v ustáleném stavu request udělá +hrst `stat` volání a nula zápisů. Po upgradu je to jedna kopie na změněný bundle, +na jednom requestu. Kopie přistávají přes dočasný soubor a atomický přesun, takže +prohlížeč stahující bundle uprostřed kopírování nikdy nedostane půlku. + +Záleží na tom víc, než to zní. Servírování bundlů z **routy** balíčku funguje jen +tehdy, když se request dostane do PHP — a hodně rozšířené nastavení webserveru +odpovídá na `.js` samo: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # routa není soubor na disku → 404 +} +``` + +Na sdíleném hostingu tenhle blok často není váš, abyste ho měnili — a úplně stejně +rozbíjí i Livewire vlastní `/livewire/livewire.js`. Soubor, který existuje, +naservíruje každá konfigurace webserveru, jaká je, a proto vám ho balíčky připraví. + +**Publikování je pořád podporované** a dělá tutéž kopii dopředu, čímž ji sundá +z prvního requestu po nasazení: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +`laravel-assets` je tag, který skeleton Laravelu už spouští ze svého composer +`post-update-cmd`, takže `composer update` udržuje kopie aktuální sám od sebe. Ani +příkaz, ani ten hook nejsou povinné. + +### Když `public/` není zapisovatelné + +Read-only kontejner, Vapor, zpevněné nasazení: nic nespadne. Bundly servíruje routa +přesně jako předtím, a chcete buď publikovací příkaz výše (spuštěný při buildu, kdy +je filesystém ještě zapisovatelný), nebo `try_files … /index.php?$query_string`, aby +byla routa dosažitelná. + +Pokud tam už **starší** kopie je, servíruje se dál, místo aby se spadlo na routu, +která nemusí být dosažitelná — a konzole to řekne, na každé stránce a bez ohledu na +`APP_DEBUG`, včetně názvů bundlů a příkazu, který to spraví. Viz +[Řešení potíží](troubleshooting.md#javascriptove-404-a-wirex-is-not-defined). + ## Publikování konfigurace (volitelné) ```bash diff --git a/docs/cs/troubleshooting.md b/docs/cs/troubleshooting.md index 832a07f0..d55b323d 100644 --- a/docs/cs/troubleshooting.md +++ b/docs/cs/troubleshooting.md @@ -134,6 +134,60 @@ Viz [Začínáme → JavaScriptové assety](getting-started.md#javascriptove-ass --- +## JavaScriptové 404 a `wireX is not defined` + +**Příznak:** Tentýž `ReferenceError` jako v předchozí sekci, ale na *každé* stránce +a bez ohledu na to, jak jste se na ni dostali — objeví se i po tvrdém reloadu. +V network tabu jsou 404 na +`/wire-core/assets/dropdown.js`, `/wire-table/assets/records.js` nebo sourozence +pod `/wire-forms/…` či `/wire-sortable/…`. + +**Příčina:** Sešly se dvě věci. Balíčky si normálně bundly zkopírují do +`public/vendor/` a emitují *tyhle* cesty, takže PHP nic neřeší — URL +`/wire-core/assets/…` ve vašem markupu znamená, že se kopie nepovedla a zaskakuje +za ni routa balíčku. A váš webserver na tu routu odpovídá sám, místo aby ji předal +PHP. Standardní nginx konfigurace Laravelu posílá cokoliv, co není na disku, do +`index.php`, konfigurace s blokem pro statické assety už ne: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # routa není soubor na disku → 404, PHP to nikdy neuvidí +} +``` + +Nic z toho není specifické pro tyhle balíčky: tentýž blok vrací 404 i na Livewire +vlastní `/livewire/livewire.js`. + +**Řešení — zapisovatelné `public/`, nebo kopie při buildu.** Obvyklou příčinou je +`public/`, do kterého webový uživatel nesmí zapisovat, nebo read-only kontejner. +Buď zápis povolte, nebo kopii udělejte, dokud je filesystém ještě zapisovatelný: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +**Nebo zpřístupněte routu** tím, že necháte blok propadnout do front controlleru — +správná odpověď tam, kde zapisovatelné `public/` opravdu není ve hře: + +```nginx +location ~* \.(js|css)$ { + try_files $uri /index.php?$query_string; // [tl! focus] +} +``` + +Příbuzné varování, když kopie existují, ale po upgradu je nešlo obnovit: + +```text +wireStack: the published copies of wire-core/dropdown are older than the bundles +the packages ship, and are what this page just loaded. +``` + +Stránka funguje dál — starý bundle je lepší než žádný — ale stojí za tím tentýž +problém se zápisem. Viz +[Začínáme → JavaScriptové assety](getting-started.md#javascriptove-assety). + +--- + ## Řazení přestalo fungovat, nebo můj kód přišel o `window.Sortable` diff --git a/docs/getting-started.md b/docs/getting-started.md index 5613746f..5ae677c6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -11,7 +11,7 @@ This guide covers the production setup for Wire in a Laravel application. | Dependency | Version | |------------|---------| | PHP | ^8.2 | -| Laravel | 10, 11, or 12 | +| Laravel | 12 or 13 | | Livewire | 3.x | | Tailwind CSS | 3.x+ | | Alpine.js | 3.x+ (included with Livewire) | @@ -166,8 +166,8 @@ Wire's interactive parts — dropdowns, the row context menu, tabs, wizards, inline-edit cells, the fill handle, row selection, record actions, drag & drop reordering — are small Alpine components delivered as pre-built bundles from inside the packages. There is nothing to install, nothing to publish and no build -step on your side: each package serves its bundles from its own route, cache-busted -by the file's modification time. +step on your side: the packages copy their bundles into `public/vendor/` themselves +and serve them as static files, cache-busted by the file's modification time. **`@wireStackScripts` puts every installed package's bundles in the document.** One line in the layout ``, and every controller is present on every page: @@ -213,6 +213,60 @@ convenience, it is the only placement the cached back/forward path cannot beat. > it when it renders. Charts additionally need Chart.js, which stays your app's own > dependency. +### Where the files actually come from + +They are **real files under `public/vendor/`**, and they get there on +their own. The first page render after a deploy copies each package's bundles out +of the installed package and into `public/`, then emits those paths: + +```html + +``` + +Nothing to run, nothing to configure. The copy is incremental — a file already +present and current is left alone — so in steady state a request does a handful of +`stat` calls and no writes at all. After an upgrade it is one copy per changed +bundle, on one request. Copies land through a temporary file and an atomic rename, +so a browser fetching a bundle mid-copy never receives a half-written one. + +This matters more than it sounds. Serving the bundles from a package *route* only +works if the request reaches PHP, and a very common web-server layout answers `.js` +itself: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # a route is not a file on disk → 404 +} +``` + +On shared hosting that block is frequently not yours to change — and it breaks +Livewire's own `/livewire/livewire.js` in exactly the same way. A file that exists +is served by every web server configuration there is, so that is what the packages +ship you. + +**Publishing is still supported** and does the same copy ahead of time, which moves +it off the first request after a deploy: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +`laravel-assets` is the tag the Laravel skeleton already runs from its composer +`post-update-cmd`, so `composer update` keeps the copies current on its own. Neither +the command nor the hook is required. + +### If `public/` is not writable + +A read-only container, Vapor, a hardened deployment: nothing throws. The package +route serves the bundles exactly as it did before, and you want either the publish +command above (run at build time, when the filesystem still is writable) or the +`try_files … /index.php?$query_string` fall-through so the route is reachable. + +If an *older* copy is already there, it keeps being served rather than falling back +to a route that may be unreachable — and the console says so, on every page and +regardless of `APP_DEBUG`, naming the bundles and the command that fixes them. See +[Troubleshooting](troubleshooting.md#javascript-404s-and-wirex-is-not-defined). + ## Config Publishing (optional) ```bash diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8890c73f..cb1b7e48 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -134,6 +134,59 @@ See [Getting Started → JavaScript Assets](getting-started.md#javascript-assets --- +## JavaScript 404s and `wireX is not defined` + +**Symptom:** The same `ReferenceError` as the previous entry, but on *every* page +and however you reached it — a hard reload shows it too. The network tab has 404s on +`/wire-core/assets/dropdown.js`, `/wire-table/assets/records.js`, or a sibling +under `/wire-forms/…` or `/wire-sortable/…`. + +**Cause:** Two things went wrong together. The packages normally copy their bundles +into `public/vendor/` and emit *those* paths, so nothing hits PHP — a +`/wire-core/assets/…` URL in your markup means that copy could not be made and the +package route is standing in for it. And your web server is answering the route +itself instead of forwarding it to PHP. The stock Laravel nginx config sends +anything not on disk to `index.php`, but a config with a static-asset block does not: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # a route is not a file on disk → 404, PHP never sees it +} +``` + +Nothing here is package-specific: the same block 404s Livewire's own +`/livewire/livewire.js`. + +**Fix — make `public/` writable, or write it at build time.** The usual cause is a +`public/` the web user cannot write to, or a read-only container. Either grant the +write, or do the copy while the filesystem still is writable: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +**Or make the route reachable**, by letting the block fall through to the front +controller — the right answer where a writable `public/` is genuinely not on offer: + +```nginx +location ~* \.(js|css)$ { + try_files $uri /index.php?$query_string; // [tl! focus] +} +``` + +A related warning, when copies exist but could not be refreshed after an upgrade: + +```text +wireStack: the published copies of wire-core/dropdown are older than the bundles +the packages ship, and are what this page just loaded. +``` + +The page still works — an old bundle beats no bundle — but the same writability +problem is behind it. See +[Getting Started → JavaScript Assets](getting-started.md#javascript-assets). + +--- + ## Reordering stops working, or my own code loses `window.Sortable` **Symptom:** After upgrading, your application's own JavaScript throws diff --git a/packages/boost/composer.json b/packages/boost/composer.json index bc198d0f..b98378fd 100644 --- a/packages/boost/composer.json +++ b/packages/boost/composer.json @@ -12,16 +12,16 @@ "require": { "php": "^8.2", "nyoncode/wire-core": "^1.0|@dev", - "illuminate/support": "^11.0|^12.0|^13.0", - "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "illuminate/console": "^12.0|^13.0", "laravel/mcp": "^0.8", "livewire/livewire": "^3.0", - "nyoncode/laravel-package-toolkit": "^2.0.1" + "nyoncode/laravel-package-toolkit": "^2.3.0" }, "require-dev": { - "orchestra/testbench": "^9.0|^10.0|^11.0", + "orchestra/testbench": "^10.0|^11.0", "laravel/pint": "^1.29", - "pestphp/pest": "^2.0|^3.0|^4.0" + "pestphp/pest": "^4.0|^5.0" }, "suggest": { "nyoncode/wire-forms": "Enables form introspection tools.", diff --git a/packages/boost/resources/boost/docs/configuration.md b/packages/boost/resources/boost/docs/configuration.md index 4f184a59..5763ad5b 100644 --- a/packages/boost/resources/boost/docs/configuration.md +++ b/packages/boost/resources/boost/docs/configuration.md @@ -31,9 +31,10 @@ You only need the tags for packages you installed. ## JavaScript Assets -There is no asset configuration and nothing to publish: every package serves its -own pre-built bundles from its own route, cache-busted by file modification time. -The one thing your app decides is *where* they are emitted — put +Nothing needs configuring and nothing needs publishing: every package copies its +own pre-built bundles into `public/vendor/` and serves them as static +files, cache-busted by file modification time. The one thing your app decides is +*where* they are emitted — put ```blade @wireStackScripts @@ -44,6 +45,10 @@ the initial document, which is what keeps them working across `wire:navigate` (including the cached Back/Forward path). Pass a package name — `@wireStackScripts('wire-table')` — to emit only one package's bundles. +`php artisan vendor:publish --tag=laravel-assets --force` does the same copy ahead +of time, which moves it off the first request after a deploy — useful, never +required. There is no config key either way. + Full explanation in [Getting Started → JavaScript Assets](getting-started.md#javascript-assets). ## Core diff --git a/packages/boost/resources/boost/docs/getting-started.md b/packages/boost/resources/boost/docs/getting-started.md index 5613746f..5ae677c6 100644 --- a/packages/boost/resources/boost/docs/getting-started.md +++ b/packages/boost/resources/boost/docs/getting-started.md @@ -11,7 +11,7 @@ This guide covers the production setup for Wire in a Laravel application. | Dependency | Version | |------------|---------| | PHP | ^8.2 | -| Laravel | 10, 11, or 12 | +| Laravel | 12 or 13 | | Livewire | 3.x | | Tailwind CSS | 3.x+ | | Alpine.js | 3.x+ (included with Livewire) | @@ -166,8 +166,8 @@ Wire's interactive parts — dropdowns, the row context menu, tabs, wizards, inline-edit cells, the fill handle, row selection, record actions, drag & drop reordering — are small Alpine components delivered as pre-built bundles from inside the packages. There is nothing to install, nothing to publish and no build -step on your side: each package serves its bundles from its own route, cache-busted -by the file's modification time. +step on your side: the packages copy their bundles into `public/vendor/` themselves +and serve them as static files, cache-busted by the file's modification time. **`@wireStackScripts` puts every installed package's bundles in the document.** One line in the layout ``, and every controller is present on every page: @@ -213,6 +213,60 @@ convenience, it is the only placement the cached back/forward path cannot beat. > it when it renders. Charts additionally need Chart.js, which stays your app's own > dependency. +### Where the files actually come from + +They are **real files under `public/vendor/`**, and they get there on +their own. The first page render after a deploy copies each package's bundles out +of the installed package and into `public/`, then emits those paths: + +```html + +``` + +Nothing to run, nothing to configure. The copy is incremental — a file already +present and current is left alone — so in steady state a request does a handful of +`stat` calls and no writes at all. After an upgrade it is one copy per changed +bundle, on one request. Copies land through a temporary file and an atomic rename, +so a browser fetching a bundle mid-copy never receives a half-written one. + +This matters more than it sounds. Serving the bundles from a package *route* only +works if the request reaches PHP, and a very common web-server layout answers `.js` +itself: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # a route is not a file on disk → 404 +} +``` + +On shared hosting that block is frequently not yours to change — and it breaks +Livewire's own `/livewire/livewire.js` in exactly the same way. A file that exists +is served by every web server configuration there is, so that is what the packages +ship you. + +**Publishing is still supported** and does the same copy ahead of time, which moves +it off the first request after a deploy: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +`laravel-assets` is the tag the Laravel skeleton already runs from its composer +`post-update-cmd`, so `composer update` keeps the copies current on its own. Neither +the command nor the hook is required. + +### If `public/` is not writable + +A read-only container, Vapor, a hardened deployment: nothing throws. The package +route serves the bundles exactly as it did before, and you want either the publish +command above (run at build time, when the filesystem still is writable) or the +`try_files … /index.php?$query_string` fall-through so the route is reachable. + +If an *older* copy is already there, it keeps being served rather than falling back +to a route that may be unreachable — and the console says so, on every page and +regardless of `APP_DEBUG`, naming the bundles and the command that fixes them. See +[Troubleshooting](troubleshooting.md#javascript-404s-and-wirex-is-not-defined). + ## Config Publishing (optional) ```bash diff --git a/packages/boost/resources/boost/docs/troubleshooting.md b/packages/boost/resources/boost/docs/troubleshooting.md index 8890c73f..cb1b7e48 100644 --- a/packages/boost/resources/boost/docs/troubleshooting.md +++ b/packages/boost/resources/boost/docs/troubleshooting.md @@ -134,6 +134,59 @@ See [Getting Started → JavaScript Assets](getting-started.md#javascript-assets --- +## JavaScript 404s and `wireX is not defined` + +**Symptom:** The same `ReferenceError` as the previous entry, but on *every* page +and however you reached it — a hard reload shows it too. The network tab has 404s on +`/wire-core/assets/dropdown.js`, `/wire-table/assets/records.js`, or a sibling +under `/wire-forms/…` or `/wire-sortable/…`. + +**Cause:** Two things went wrong together. The packages normally copy their bundles +into `public/vendor/` and emit *those* paths, so nothing hits PHP — a +`/wire-core/assets/…` URL in your markup means that copy could not be made and the +package route is standing in for it. And your web server is answering the route +itself instead of forwarding it to PHP. The stock Laravel nginx config sends +anything not on disk to `index.php`, but a config with a static-asset block does not: + +```nginx +location ~* \.(js|css)$ { + try_files $uri =404; # a route is not a file on disk → 404, PHP never sees it +} +``` + +Nothing here is package-specific: the same block 404s Livewire's own +`/livewire/livewire.js`. + +**Fix — make `public/` writable, or write it at build time.** The usual cause is a +`public/` the web user cannot write to, or a read-only container. Either grant the +write, or do the copy while the filesystem still is writable: + +```bash +php artisan vendor:publish --tag=laravel-assets --force +``` + +**Or make the route reachable**, by letting the block fall through to the front +controller — the right answer where a writable `public/` is genuinely not on offer: + +```nginx +location ~* \.(js|css)$ { + try_files $uri /index.php?$query_string; // [tl! focus] +} +``` + +A related warning, when copies exist but could not be refreshed after an upgrade: + +```text +wireStack: the published copies of wire-core/dropdown are older than the bundles +the packages ship, and are what this page just loaded. +``` + +The page still works — an old bundle beats no bundle — but the same writability +problem is behind it. See +[Getting Started → JavaScript Assets](getting-started.md#javascript-assets). + +--- + ## Reordering stops working, or my own code loses `window.Sortable` **Symptom:** After upgrading, your application's own JavaScript throws diff --git a/packages/core/composer.json b/packages/core/composer.json index fc5fa436..9cf18cbf 100644 --- a/packages/core/composer.json +++ b/packages/core/composer.json @@ -11,14 +11,14 @@ ], "require": { "php": "^8.2", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", "livewire/livewire": "^3.0", - "nyoncode/laravel-package-toolkit": "^2.0.1" + "nyoncode/laravel-package-toolkit": "^2.3.0" }, "require-dev": { - "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "orchestra/testbench": "^10.0|^11.0", "laravel/pint": "^1.29", - "pestphp/pest": "^2.0|^3.0|^4.0" + "pestphp/pest": "^4.0|^5.0" }, "autoload": { "psr-4": { diff --git a/packages/core/src/Foundation/Assets/AssetManager.php b/packages/core/src/Foundation/Assets/AssetManager.php index 1ca6e01b..afc25d67 100644 --- a/packages/core/src/Foundation/Assets/AssetManager.php +++ b/packages/core/src/Foundation/Assets/AssetManager.php @@ -82,10 +82,74 @@ public function getScripts(?string $package = null): array */ public function renderScripts(?string $package = null): HtmlString { - return $this->rendered[$package ?? '*'] ??= new HtmlString(implode("\n", array_map( + if (isset($this->rendered[$package ?? '*'])) { + return $this->rendered[$package ?? '*']; + } + + // The tags first, the warning second, and the order is load-bearing: resolving + // a URL is what runs the mirror, so judging staleness before that would find + // every copy out of date on the first request after an upgrade — and warn + // about a state the very next line repairs. + $tags = array_map( static fn (Asset $asset): string => $asset->toHtml(), $this->getScripts($package), + ); + + return $this->rendered[$package ?? '*'] = new HtmlString(implode("\n", array_filter([ + $this->stalePublishWarning($package), + ...$tags, + ]))); + } + + /** + * A console warning when `public/vendor` holds a copy of a bundle older than the + * one the package ships. + * + * That copy **is** what the page loads ({@see PublishedAssets} explains why + * dropping back to the route is not an option), so this is the only thing + * standing between an app and last release's JavaScript running against this + * release's markup. It is therefore not gated on `app.debug`: the deployment that + * gets this wrong is a production one, where a debug-only warning would never be + * seen. Livewire warns unconditionally for the same reason. + */ + private function stalePublishWarning(?string $package): ?string + { + $stale = array_values(array_unique(array_map( + static fn (Asset $asset): string => $asset->getPackage().'/'.$asset->getId(), + array_filter($this->getScripts($package), static fn (Asset $asset): bool => $asset->isStale()), ))); + + if ($stale === []) { + return null; + } + + return ''; + } + + /** + * Forget every resolved URL and rendered tag, keeping the registry itself. + * + * For a long-lived worker (Octane), where these memos would otherwise outlive the + * deploy that changed the files. A bundle's `?id=` is the mtime of its mirrored + * copy, so a worker holding last release's query string is a worker whose + * `data-navigate-track` never fires — the browser keeps a bundle the mirror has + * already replaced on disk. Re-binding each asset to its package is what clears + * its URL, the same path {@see register()} takes. + */ + public function flushUrls(): void + { + foreach ($this->assets as $package => $group) { + foreach ($group as $id => $asset) { + $this->assets[$package][$id] = $asset->withPackage($package); + } + } + + $this->rendered = []; } /** diff --git a/packages/core/src/Foundation/Assets/Contracts/Asset.php b/packages/core/src/Foundation/Assets/Contracts/Asset.php index 01df2edc..c449d35e 100644 --- a/packages/core/src/Foundation/Assets/Contracts/Asset.php +++ b/packages/core/src/Foundation/Assets/Contracts/Asset.php @@ -5,6 +5,7 @@ namespace NyonCode\WireCore\Foundation\Assets\Contracts; use Illuminate\Contracts\Support\Htmlable; +use NyonCode\LaravelPackageToolkit\Support\PublishedAssets; use NyonCode\WireCore\Foundation\Assets\AssetManager; /** @@ -35,6 +36,14 @@ public function withPackage(string $package): static; */ public function getUrl(): string; + /** + * Whether a copy of this asset was published into `public/vendor` and is now + * older than the one the package ships — the app upgraded without re-publishing. + * That copy is still what {@see PublishedAssets} serves, so this is what lets the + * manager warn about it. + */ + public function isStale(): bool; + /** * Whether the asset is fetched on demand by the surface that needs it, rather * than emitted into every document. Heavy, optional bundles (a rich-text editor, diff --git a/packages/core/src/Foundation/Assets/Js.php b/packages/core/src/Foundation/Assets/Js.php index 8ed83a17..7d353dbf 100644 --- a/packages/core/src/Foundation/Assets/Js.php +++ b/packages/core/src/Foundation/Assets/Js.php @@ -4,27 +4,33 @@ namespace NyonCode\WireCore\Foundation\Assets; +use NyonCode\LaravelPackageToolkit\Support\PublishedAssets; use NyonCode\WireCore\Exceptions\AssetRegistrationException; use NyonCode\WireCore\Foundation\Assets\Contracts\Asset; /** * A JavaScript bundle shipped inside a package's `dist/`. * - * Delivery is deliberately publish-free: every package already exposes a named - * asset route (`wire-core.asset`, `wire-table.asset`, `wire-forms.asset`) that - * streams the file straight out of the package, so a consumer needs neither npm nor - * `vendor:publish`. This value object is the declaration of one such bundle — the - * route name follows from the registering package (`{package}.asset`) and the + * Delivery needs neither npm nor `vendor:publish` from a consumer. The toolkit + * mirrors each package's `dist/` into `public/vendor/{package}` and this object + * emits that path — a real file, which is what makes it survive a web server that + * answers `.js` from `try_files $uri =404` and never forwards it to PHP. See + * {@see PublishedAssets} for the mirror, which is lazy, incremental and atomic. + * + * Where `public/` cannot be written the mirror returns nothing and the package's own + * asset route (`wire-core.asset`, `wire-table.asset`, …) streams the file straight + * out of the package instead. This value object is the declaration of one bundle — + * the route name follows from the registering package (`{package}.asset`) and the * `{asset}` parameter is the bundle's id, which keeps registration to one line. * * A path that already looks like a URL (`https://…`, `//…`) is used verbatim, the * same way Filament detects remoteness; there is no `remote()` builder to get wrong. * - * Local bundles are cache-busted by the file's mtime (`?id=`), the convention - * the per-surface partials already used. That query string is also what makes - * `data-navigate-track` meaningful: Livewire full-page-reloads a `wire:navigate` - * visit when a tracked asset's query string changed, so a deploy is picked up - * instead of running new markup against a stale bundle. + * Local bundles are cache-busted by an mtime (`?id=`) — the mirrored copy's + * where there is one, the shipped file's otherwise. That query string is also what + * makes `data-navigate-track` meaningful: Livewire full-page-reloads a + * `wire:navigate` visit when a tracked asset's query string changed, so an upgrade + * is picked up instead of running new markup against a bundle the browser cached. */ final class Js implements Asset { @@ -141,12 +147,29 @@ public function getUrl(): string throw AssetRegistrationException::notRegistered($this->id); } + // The toolkit mirrors each package's dist/ into public/vendor and hands back + // that path — a real file, which is what a web server answering `.js` from a + // `try_files $uri =404` block will serve. The route below is the fallback for + // a deployment whose public/ cannot be written. + $published = app(PublishedAssets::class)->url($this->package, $this->path); + + if ($published !== null) { + return $this->url = $published; + } + $version = @filemtime($this->path) ?: null; return $this->url = route($this->package.'.asset', ['asset' => $this->id]) .($version ? '?id='.$version : ''); } + public function isStale(): bool + { + return ! $this->isRemote() + && $this->package !== null + && app(PublishedAssets::class)->isStale($this->package, $this->path); + } + public function toHtml(): string { return $this->html ??= ' +@else +{{-- Wrapped in an IIFE, which is what esbuild does to the same source for the + bundle above (`--format=iife`): inlined bare, its top-level `const`/`let` + would land in the document's global lexical scope, and a second copy would be + a SyntaxError taking the whole script with it. The listener guard lives on + `window` precisely so two copies still bind only once. --}} + +@endif +@endassets + +{{-- The one feedback pill for the page. Rendered here rather than created in JS + because a consumer's Tailwind scans `resources/views` and `src`, never `dist` + — a class that existed only in the bundle would never reach their CSS. + + `hidden` and the viewport coordinates are driven by the controller; the + transition is the browser's, so no Alpine is involved in showing it. --}} + diff --git a/packages/table/src/Columns/Column.php b/packages/table/src/Columns/Column.php index 8aa528fc..f2dc0c9d 100644 --- a/packages/table/src/Columns/Column.php +++ b/packages/table/src/Columns/Column.php @@ -32,6 +32,7 @@ use NyonCode\WireCore\Foundation\Icons\Icon; use NyonCode\WireCore\Foundation\Icons\IconManager; use NyonCode\WireCore\Foundation\Support\EnumResolver; +use NyonCode\WireCore\Foundation\View\Skeleton; use NyonCode\WireTable\Concerns\CanBeFiltered; use NyonCode\WireTable\Concerns\CanBeSummarized; use NyonCode\WireTable\Concerns\HasResponsive; @@ -755,18 +756,25 @@ public function renderCell(Model $record): string } /** - * §7 proof-of-concept: an Htmlable cell skeleton. + * §7: the Htmlable cell skeleton. * - * For a plain display column the text partial's per-record variation is *only* - * the content string — classes, icon, static tooltip/description are column-static. - * So the partial is rendered ONCE into a skeleton with a content placeholder, and - * every row splices its escaped state in — a string op, not a `view()->render()`. - * Falls back to {@see renderCell()} when a per-record structural bit is present - * (url / copy / description-closure), which a single skeleton cannot splice. + * The text partial is rendered ONCE into a {@see Skeleton} and every row splices + * its own values in — a string op, not a `view()->render()`. What varies per + * record is only ever a *value*: the content, and — since the multi-slot move — + * a per-record url, copy value, description-closure or icon-closure too. Those + * four used to drop the column back onto the per-cell render, measured at 18–33× + * the cost of a splice (and 3.3× on whole-table mount when every column carried + * one), which is the entire reason this now has more than one hole in it. + * + * Structure, as opposed to value, is what a skeleton cannot splice: a url present + * on one row and absent on the next are two shapes. So skeletons are cached per + * shape rather than one per column — O(shapes) renders, and in practice one. + * + * @var array */ - private const CELL_TOKEN = 'ᐊWIRE_CELL_a3f9e1ᐊ'; + private array $cellSkeletons = []; - private ?string $cellSkeleton = null; + private ?string $staticIconHtml = null; public function renderCellFast(Model $record): string { @@ -774,10 +782,9 @@ public function renderCellFast(Model $record): string return ''; } - // Subclasses that override renderCell render a different view than the text - // skeleton, and non-skeletonable columns vary structurally per row — both - // fall back to the full, byte-identical render. - if (! $this->supportsCellSkeleton() || ! $this->isCellSkeletonable()) { + // A subclass that overrides renderCell renders a different view than the text + // skeleton, so it falls back to its own full, byte-identical render. + if (! $this->supportsCellSkeleton()) { return $this->renderCell($record); } @@ -786,11 +793,55 @@ public function renderCellFast(Model $record): string ? (string) ($this->displayUsing)($state, $record) : $this->formatValue($state, $record); - return trim(str_replace( - self::CELL_TOKEN, - $this->html ? $content : e($content), - $this->cellSkeleton(), - )); + // Resolved per record ONLY where the column's own config is per-record. A + // plain text column pays three null checks here, not three resolutions — + // which is what keeps the common path exactly as cheap as it was. + $url = $this->urlCallback !== null ? $this->getUrl($record) : null; + $description = $this->description instanceof Closure + ? ($this->description)($record) + : (is_string($this->description) ? $this->description : null); + $iconHtml = $this->icon instanceof Closure + ? $this->iconHtmlFor($record) + : ($this->staticIconHtml ??= $this->iconHtmlFor(null)); + + $shape = ($url !== null && $url !== '' ? 'u' : '') + .($description !== null && $description !== '' ? 'd' : '') + .($iconHtml !== '' ? 'i' : ''); + + $skeleton = $this->cellSkeletons[$shape] + ??= $this->buildCellSkeleton($url, $description, $iconHtml); + + // Each value arrives encoded exactly as the partial would have encoded it in + // that position: content raw or escaped per ->html(), url/description/copy + // value through e() because the partial escapes them, icon markup raw. + // + // Built branch by branch to match the shape above rather than as one literal: + // a value for a slot this shape does not have is work every row pays for + // nothing — which is how the §5 copyMessage regression happened, and + // EnumResolver::scalar() on every cell of every non-copyable column would be + // the same mistake again. + $values = ['content' => $this->html ? $content : e($content)]; + + if ($url !== null && $url !== '') { + $values['url'] = e($url); + } + + if ($this->copyable) { + $values['copyValue'] = e((string) EnumResolver::scalar($state)); + } + + if ($description !== null && $description !== '') { + $values['description'] = e($description); + } + + if ($iconHtml !== '') { + $values['icon'] = $iconHtml; + } + + // The trim is the partial's own — a class-less, non-html cell is bare text, + // so surrounding whitespace in the state would otherwise survive here and + // not in renderCell(). + return trim($skeleton->fill($values)); } /** @var array */ @@ -808,38 +859,35 @@ private function supportsCellSkeleton(): bool } /** - * Skeletonable = the only per-record value is the content. A per-record url, - * copy affordance, or description-closure changes structure row to row. + * Render the partial once for one cell *shape*, with a sentinel wherever a value + * varies by record. + * + * The three arguments are the resolved values for THIS shape, and only their + * presence is read: they decide whether the partial builds the ``, the + * description block and the icon at all. Their content arrives later, through + * {@see Skeleton::fill()}. */ - private function isCellSkeletonable(): bool + private function buildCellSkeleton(?string $url, ?string $description, string $iconHtml): Skeleton { - return $this->urlCallback === null - && ! $this->copyable - && ! ($this->description instanceof Closure) - // A closure icon is per-record, so the cell is not fully static. - && ! ($this->icon instanceof Closure); - } - - private function cellSkeleton(): string - { - return $this->cellSkeleton ??= trim($this->renderView('tables.columns.text', [ - 'content' => self::CELL_TOKEN, + return Skeleton::compile($this->renderView('tables.columns.text', [ + 'content' => Skeleton::slot('content'), 'textClasses' => $this->getTextClasses(), - // Build raw so the token is not escaped; the per-row splice escapes state. + // Built raw so no sentinel is escaped here; each value is encoded for its + // own position when a row splices it in. 'isHtml' => true, - // A closure icon is per-record and excluded from the skeleton - // (isCellSkeletonable), so here $this->icon is only ever a literal. - 'iconHtml' => $this->iconHtmlFor(null), + 'iconHtml' => $iconHtml === '' ? '' : Skeleton::slot('icon'), 'iconPosition' => $this->iconPosition ?? 'before', - 'url' => null, + 'url' => ($url === null || $url === '') ? null : Skeleton::slot('url'), 'openInNewTab' => $this->openUrlInNewTab, - 'copyable' => false, - 'copyValue' => null, - 'copyMessage' => null, + 'copyable' => $this->copyable, + 'copyValue' => $this->copyable ? Skeleton::slot('copyValue') : null, + // Column-static, so it stays baked in rather than becoming a slot. Same + // §5 guard as renderCell(): resolved only when the column is copyable. + 'copyMessage' => $this->copyable ? ($this->copyMessage ?? Trans::get('wire-table::messages.copied')) : null, 'tooltip' => $this->tooltip, - 'description' => is_string($this->description) ? $this->description : null, + 'description' => ($description === null || $description === '') ? null : Skeleton::slot('description'), 'descriptionPosition' => $this->descriptionPosition, - ])); + ]), 'content', 'icon', 'url', 'copyValue', 'description'); } public function canView(): bool @@ -1057,8 +1105,8 @@ public function getTextClasses(): string * The icon may be a per-record Closure ({@see HasIcon::icon()}); it is * resolved with the record (evaluated closures may also return an Icon enum), * so a closure icon can never reach renderIcon(string) raw. Passing a null - * record (the shared skeleton path) resolves only a literal icon — closure - * icons are excluded from the skeleton by isCellSkeletonable(). + * record resolves only a literal icon — that is the column-static case the + * skeleton bakes in, where a closure icon is spliced per row through its slot. */ private function iconHtmlFor(?Model $record): string { diff --git a/packages/table/tests/Feature/CopyAssetTest.php b/packages/table/tests/Feature/CopyAssetTest.php new file mode 100644 index 00000000..96a4543f --- /dev/null +++ b/packages/table/tests/Feature/CopyAssetTest.php @@ -0,0 +1,72 @@ +`. What that trades away is + * self-containment — the markup no longer carries its own behaviour — so these pin + * the two ways the bundle could silently stop arriving. + */ +test('the copy bundle is shipped inside the package', function () { + $bundle = WireTableServiceProvider::ASSETS_PATH.'/wire-table-copy.js'; + + expect(is_file($bundle))->toBeTrue() + ->and(file_get_contents($bundle))->toContain('data-copy'); +}); + +test('the package serves the copy bundle without publishing or a build step', function () { + $response = $this->get('/wire-table/assets/copy.js'); + + $response->assertOk(); + expect($response->headers->get('Content-Type'))->toContain('javascript'); + expect($response->baseResponse)->toBeInstanceOf(BinaryFileResponse::class); +}); + +test('the shipped bundle carries the whole copy surface', function () { + // The click delegation, the clipboard write and the shared feedback pill. + // Fails if the dist drifts from source (needs `npm run build:table-assets`). + expect(file_get_contents(WireTableServiceProvider::ASSETS_PATH.'/wire-table-copy.js')) + ->toContain('[data-copy]') + ->toContain('data-copy-feedback') + ->toContain('data-copy-message') + ->toContain('writeText') + ->toContain('addEventListener("click"'); +}); + +test('the raw copy source stays import-free for the inline fallback', function () { + // When the compiled bundle is missing, copy-assets.blade.php inlines this file + // verbatim; an import statement would turn the fallback into a syntax error + // inside a classic - + - + Date: Sun, 9 Aug 2026 23:57:50 +0200 Subject: [PATCH 22/30] Log the three 1.17.0 changes that shipped without an entry The toolbar's header-action fold, the context menu surviving a poll tick, and the searchAs() guard all landed with tests, docs and browser drivers but no changelog line. The guard goes under Changed rather than Added: an application carrying that mismatch today has a search that already finds nothing, and will now be told so on the next render. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d0e65bf..e0301700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,18 @@ All notable changes to the Wire ecosystem will be documented in this file. ### Added - **The TipTap editor speaks Czech, and opens on a pre-formatted document — `->default()`.** Its toolbar tooltips were bare `__('Bold')` keys, which only an *app-level* translation file could ever answer: a Czech app shipped a fully translated form with an English editor bolted into the middle of it, and the two strings that live inside the JS bundle — the link and image `prompt()` titles — could not be translated at all. All of it now resolves from the package's own vocabulary (`wire-forms::fields.editor.*`, `en` + `cs`), including the prompt titles, which are read in PHP and handed to the editor through its Alpine config rather than being hardcoded in the bundle; the group is named `editor` rather than `tiptap` because **RichEditor and MarkdownEditor title their toolbars from the very same keys** — one vocabulary for all three editors, so they read alike in every locale and a reworded button is reworded once. RichEditor's link prompt moved from `prompt('{{ __('Enter URL') }}')` to `@js()`, which hex-escapes both quote characters: the old form rendered an apostrophe as `'`, and a locale whose wording contains one would have closed the JS string and, with it, the `x-data` attribute around it. Headings read as *Heading 2* / *Nadpis 2* rather than `H2` — the glyph on the button stays `H1`/`H2`/`H3` in every locale, since those are symbols, not words. Separately, a starting template is now the canonical `->default()` and **not** a second editor-only method: the form runtime already seeds it into the state bag, and the field additionally hands it to the editor, which applies it when the bound value is empty and pushes the parsed document back into Livewire — so a host that never seeded (a `null` column, a hand-bound property) still opens on the template, and saving an untouched form stores it rather than nothing. The default is markup, so `'

Zápis z porady

Nějaký text

'` arrives formatted; under `->outputJson()` it may be a JSON document string *or* the same HTML, which is where the old code dropped it — a non-JSON value was parsed with a `catch { return {} }` and became an empty editor. Re-opening a document the user deliberately cleared does not bring the default back: an emptied editor stores `

`, not `''`. Browser-verified by `workbench/scripts/verify-tiptap-split.mjs` (14/14) against a new `/previews/field-tiptap-default`, which reads the seeded document out of Livewire's state, not just off the screen. See `docs/forms/fields/tiptap-editor.md`. - **JS bundles are served as static files out of `public/vendor`, and get there by themselves.** Serving a bundle from a package route only works when the request reaches PHP, and a very common nginx layout answers `.js` from a `try_files $uri =404` block that never forwards it — the same block 404s Livewire's own `/livewire/livewire.js`. On shared hosting that block is frequently not the application's to change, so a delivery mode whose correctness depends on a vhost the app cannot edit is not a delivery mode. It is now files: the first page render after a deploy mirrors each package's `dist/` into `public/vendor/` and `@wireStackScripts` emits those paths. No command, no composer hook, no config key — and nothing for an app that already worked to do. The mirror is **incremental** (only a file missing or older than the shipped one is copied, so steady state is a handful of `stat` calls and zero writes, and an upgrade is one copy per changed bundle on one request), **atomic** (copies land through a temp file and `rename()`, so a browser fetching mid-copy never gets a truncated bundle — which would be a syntax error taking every controller in it down), **whole-directory** (TipTap's entry imports `./chunk-.js`, which the browser fetches itself and PHP is never asked to resolve, so a mirror driven only by registered bundles would break the editor), and **lazy** rather than booted (mirroring from `boot()` would put a directory walk on every queue job and API route that will never emit a ` +@if(is_file($copyAssetFile)) +@packageScripts('wire-core', 'wire-core-copy.js') @else {{-- Wrapped in an IIFE, which is what esbuild does to the same source for the bundle above (`--format=iife`): inlined bare, its top-level `const`/`let` diff --git a/packages/core/resources/views/partials/floating-assets.blade.php b/packages/core/resources/views/partials/floating-assets.blade.php index acd4181f..10cf502f 100644 --- a/packages/core/resources/views/partials/floating-assets.blade.php +++ b/packages/core/resources/views/partials/floating-assets.blade.php @@ -1,13 +1,13 @@ -@php - use NyonCode\WireCore\Foundation\View\FloatingAssets; -@endphp +{{-- Pre-bundled "Teleport + Floating UI" dropdown primitive. -{{-- Pre-bundled "Teleport + Floating UI" dropdown primitive. The URL (route + - cache-busting mtime) is resolved once per request by the FloatingAssets owner. + The tag comes from the toolkit's renderer, which owns delivery (mirror, then the + package's own asset route) and puts `data-navigate-track` on it — writing the tag + here would be a second resolver for the same concern, and would silently drop the + attributes the declaration carries. Loaded through Livewire's @assets directive so the script registers once and also runs when the surface renders inside a Livewire-loaded modal (AJAX), where DOM-morphed +@packageScripts('wire-core', 'wire-core-dropdown.js') @endassets diff --git a/packages/core/resources/views/widgets/partials/chart-assets.blade.php b/packages/core/resources/views/widgets/partials/chart-assets.blade.php index 07b69197..28227978 100644 --- a/packages/core/resources/views/widgets/partials/chart-assets.blade.php +++ b/packages/core/resources/views/widgets/partials/chart-assets.blade.php @@ -1,21 +1,16 @@ -@php - // The URL (route + cache-busting mtime) is owned and memoised by the canonical - // AssetManager, which the provider registers this bundle with; recomputing it - // here would be a second resolver for the same concern. - $assetUrl = app(\NyonCode\WireCore\Foundation\Assets\AssetManager::class)->url('wire-core', 'chart'); -@endphp - {{-- Pre-bundled chart controller (wireChart). Loaded through Livewire's @assets directive — never @push, which needs a @stack('scripts') no package layout renders — so the script is emitted once per page and also runs when the widget renders inside a Livewire-loaded modal, where a DOM-morphed +@packageScripts('wire-core', 'wire-core-chart.js') @endassets diff --git a/packages/core/src/Exceptions/AssetRegistrationException.php b/packages/core/src/Exceptions/AssetRegistrationException.php deleted file mode 100644 index 4fbc1b45..00000000 --- a/packages/core/src/Exceptions/AssetRegistrationException.php +++ /dev/null @@ -1,36 +0,0 @@ -register([$asset], \'wire-core\') — which ' - .'binds the asset to the package whose asset route serves it.' - ); - } - - public static function unknown(string $package, string $id): self - { - return new self( - "No asset [{$id}] is registered for package [{$package}]. Register it in the " - ."package provider's bootedPackage() callback, e.g. register([Js::make('{$id}', " - ."\$path)], '{$package}')." - ); - } -} diff --git a/packages/core/src/Foundation/Assets/AssetManager.php b/packages/core/src/Foundation/Assets/AssetManager.php deleted file mode 100644 index afc25d67..00000000 --- a/packages/core/src/Foundation/Assets/AssetManager.php +++ /dev/null @@ -1,176 +0,0 @@ -` and every controller is - * in the initial document. That placement is the point, not a convenience: on the - * cached Back/Forward `wire:navigate` path Livewire does not wait for newly injected - * head scripts before initialising Alpine (`swapCurrentPageWithNewHtml` keeps its - * no-op continuation there), so a bundle that arrives with the new page can lose the - * race. One that was already in the document cannot. - * - * Registered as a container singleton, so the registry — and the URL memo each asset - * holds — spans the whole request. This generalises {@see FloatingAssets}, which - * memoised exactly one bundle URL for the same reason and now delegates here. - */ -final class AssetManager -{ - /** @var array> package => id => asset */ - private array $assets = []; - - /** @var array */ - private array $rendered = []; - - /** - * Register a package's assets. Re-registering an id replaces it, so an app can - * point a bundle somewhere else without a second tag being emitted. - * - * @param array $assets - * @param string $package short package name, e.g. `wire-core`; also names the asset route - */ - public function register(array $assets, string $package): void - { - foreach ($assets as $asset) { - $this->assets[$package][$asset->getId()] = $asset->withPackage($package); - } - - $this->rendered = []; - } - - /** - * The script assets to emit into the document, in registration order. - * - * Assets marked `loadedOnRequest()` are excluded — their surface fetches them - * itself. Pass a package to narrow the set to one package's bundles. - * - * @return list - */ - public function getScripts(?string $package = null): array - { - $groups = $package === null - ? $this->assets - : [$this->assets[$package] ?? []]; - - $scripts = []; - - foreach ($groups as $group) { - foreach ($group as $asset) { - if (! $asset->isLoadedOnRequest()) { - $scripts[] = $asset; - } - } - } - - return $scripts; - } - - /** - * The `'; - } - - /** - * Forget every resolved URL and rendered tag, keeping the registry itself. - * - * For a long-lived worker (Octane), where these memos would otherwise outlive the - * deploy that changed the files. A bundle's `?id=` is the mtime of its mirrored - * copy, so a worker holding last release's query string is a worker whose - * `data-navigate-track` never fires — the browser keeps a bundle the mirror has - * already replaced on disk. Re-binding each asset to its package is what clears - * its URL, the same path {@see register()} takes. - */ - public function flushUrls(): void - { - foreach ($this->assets as $package => $group) { - foreach ($group as $id => $asset) { - $this->assets[$package][$id] = $asset->withPackage($package); - } - } - - $this->rendered = []; - } - - /** - * One registered asset, including an on-request one. - * - * @throws AssetRegistrationException when nothing is registered under that id - */ - public function get(string $package, string $id): Asset - { - return $this->assets[$package][$id] - ?? throw AssetRegistrationException::unknown($package, $id); - } - - /** - * The cache-busted URL of one registered asset — for a surface that emits its - * own tag (a per-surface `@assets` partial, a lazy import). - * - * @throws AssetRegistrationException when nothing is registered under that id - */ - public function url(string $package, string $id): string - { - return $this->get($package, $id)->getUrl(); - } -} diff --git a/packages/core/src/Foundation/Assets/Bundle.php b/packages/core/src/Foundation/Assets/Bundle.php new file mode 100644 index 00000000..98f1a7a1 --- /dev/null +++ b/packages/core/src/Foundation/Assets/Bundle.php @@ -0,0 +1,94 @@ + null` *removes* a default: `Asset::classic()` adds `defer`, + * and while nothing is known to break under it — the registration idiom is + * order-independent by construction, `if (window.Alpine) register()` with + * `alpine:init` behind it — nothing had watched it in a browser either. A + * structural change should not smuggle a timing change in with it. Turning it on + * is a separate decision, behind `npm run verify:drivers`. + * + * `data-navigate-track="reload"` is the toolkit's default and is kept. + * + * @var array + */ + public const ATTRIBUTES = [ + 'data-navigate-once' => true, + 'defer' => null, + ]; + + /** + * Declare one shipped bundle. + * + * `classic()` is not optional for anything in this repo: every bundle is built + * with `--format=iife`, and the toolkit renders a `.js` entry as `type="module"` + * unless told otherwise. A module is deferred and its top-level declarations + * never reach `window` — which is exactly how the registration idiom works — so + * a bundle emitted as a module registers nothing, and every `x-data` referencing + * its factory fails with no error at the point of the mistake. + * + * @param string $file the built file, relative to the package's `dist/` + */ + public static function make(string $file): Asset + { + return Asset::make($file)->classic()->attributes(self::ATTRIBUTES); + } + + /** + * The package's own asset route, as the toolkit's `hasAssetFallback()` resolver. + * + * Reached only where `public/` cannot be written and nothing was published — + * ADR 0024 chose static files first and a route behind them, and without this the + * renderer drops the tag and says nothing. The route streams the file straight out + * of the package, so it needs no `?id=`: nothing about it is cached past the + * response headers it sets itself. + * + * Each package names its route `{short-name}.asset` and takes an id rather than a + * filename — `dropdown`, not `wire-core-dropdown.js` — so the id is read back off + * the file the entry was declared with. Stripping the package's own prefix covers + * `wire-core-dropdown.js` → `dropdown`; falling back to `wire-` covers + * `wire-sortable.js` → `sortable`, whose route builds `wire-{id}.js`. + * + * @param string $package short package name, e.g. `wire-core` + * @return Closure(string, string): ?string + */ + public static function servedByRoute(string $package): Closure + { + return static function (string $file) use ($package): ?string { + $id = basename($file, '.js'); + + foreach ([$package.'-', 'wire-'] as $prefix) { + if (str_starts_with($id, $prefix)) { + $id = substr($id, strlen($prefix)); + + break; + } + } + + return $id === '' ? null : route($package.'.asset', ['asset' => $id]); + }; + } +} diff --git a/packages/core/src/Foundation/Assets/Contracts/Asset.php b/packages/core/src/Foundation/Assets/Contracts/Asset.php deleted file mode 100644 index c449d35e..00000000 --- a/packages/core/src/Foundation/Assets/Contracts/Asset.php +++ /dev/null @@ -1,53 +0,0 @@ -` component. - */ -interface Asset extends Htmlable -{ - /** - * Identity inside the owning package. It doubles as the `{asset}` parameter of - * that package's asset route, which is what keeps registration declarative. - */ - public function getId(): string; - - /** The package that registered it, or `null` while it is still unregistered. */ - public function getPackage(): ?string; - - /** A copy of this asset bound to the package registering it. */ - public function withPackage(string $package): static; - - /** - * The cache-busted URL the browser fetches. Resolved once, then memoised — an - * asset instance is registry-lifetime, so the route and the mtime are read at - * most once per request. - */ - public function getUrl(): string; - - /** - * Whether a copy of this asset was published into `public/vendor` and is now - * older than the one the package ships — the app upgraded without re-publishing. - * That copy is still what {@see PublishedAssets} serves, so this is what lets the - * manager warn about it. - */ - public function isStale(): bool; - - /** - * Whether the asset is fetched on demand by the surface that needs it, rather - * than emitted into every document. Heavy, optional bundles (a rich-text editor, - * an image processor) opt in; the small controller that loads them must not. - */ - public function isLoadedOnRequest(): bool; -} diff --git a/packages/core/src/Foundation/Assets/Js.php b/packages/core/src/Foundation/Assets/Js.php deleted file mode 100644 index 7d353dbf..00000000 --- a/packages/core/src/Foundation/Assets/Js.php +++ /dev/null @@ -1,190 +0,0 @@ -`) — the mirrored copy's - * where there is one, the shipped file's otherwise. That query string is also what - * makes `data-navigate-track` meaningful: Livewire full-page-reloads a - * `wire:navigate` visit when a tracked asset's query string changed, so an upgrade - * is picked up instead of running new markup against a bundle the browser cached. - */ -final class Js implements Asset -{ - private ?string $package = null; - - private bool $module = false; - - private bool $defer = false; - - private bool $navigateTrack = false; - - private bool $navigateOnce = false; - - private bool $loadedOnRequest = false; - - private ?string $url = null; - - private ?string $html = null; - - private function __construct( - private readonly string $id, - private readonly string $path, - ) {} - - /** - * Declare a bundle: its id (the `{asset}` route parameter) and either an - * absolute filesystem path inside the package or an absolute URL. - */ - public static function make(string $id, string $path): self - { - return new self($id, $path); - } - - /** Load the bundle as an ES module (`type="module"`). */ - public function module(): static - { - $this->module = true; - - return $this; - } - - /** Defer execution until the document has been parsed. */ - public function defer(): static - { - $this->defer = true; - - return $this; - } - - /** Force a full page reload on `wire:navigate` when this bundle changes. */ - public function navigateTrack(): static - { - $this->navigateTrack = true; - - return $this; - } - - /** Never re-execute this bundle on a `wire:navigate` visit. */ - public function navigateOnce(): static - { - $this->navigateOnce = true; - - return $this; - } - - /** - * Keep the bundle out of the always-emitted set; the surface that needs it - * fetches it on demand. For heavy, optional bodies only — never for the small - * controller that registers an Alpine component. - */ - public function loadedOnRequest(): static - { - $this->loadedOnRequest = true; - - return $this; - } - - public function getId(): string - { - return $this->id; - } - - public function getPackage(): ?string - { - return $this->package; - } - - public function withPackage(string $package): static - { - $clone = clone $this; - $clone->package = $package; - $clone->url = null; - $clone->html = null; - - return $clone; - } - - public function isLoadedOnRequest(): bool - { - return $this->loadedOnRequest; - } - - public function getUrl(): string - { - if ($this->url !== null) { - return $this->url; - } - - if ($this->isRemote()) { - return $this->url = $this->path; - } - - if ($this->package === null) { - throw AssetRegistrationException::notRegistered($this->id); - } - - // The toolkit mirrors each package's dist/ into public/vendor and hands back - // that path — a real file, which is what a web server answering `.js` from a - // `try_files $uri =404` block will serve. The route below is the fallback for - // a deployment whose public/ cannot be written. - $published = app(PublishedAssets::class)->url($this->package, $this->path); - - if ($published !== null) { - return $this->url = $published; - } - - $version = @filemtime($this->path) ?: null; - - return $this->url = route($this->package.'.asset', ['asset' => $this->id]) - .($version ? '?id='.$version : ''); - } - - public function isStale(): bool - { - return ! $this->isRemote() - && $this->package !== null - && app(PublishedAssets::class)->isStale($this->package, $this->path); - } - - public function toHtml(): string - { - return $this->html ??= ''; - } - - /** A path that is already a URL is served as-is — no route, no mtime. */ - private function isRemote(): bool - { - return str_starts_with($this->path, 'http://') - || str_starts_with($this->path, 'https://') - || str_starts_with($this->path, '//'); - } -} diff --git a/packages/core/src/Foundation/View/FloatingAssets.php b/packages/core/src/Foundation/View/FloatingAssets.php index 84b4be8d..1b323ea9 100644 --- a/packages/core/src/Foundation/View/FloatingAssets.php +++ b/packages/core/src/Foundation/View/FloatingAssets.php @@ -4,30 +4,43 @@ namespace NyonCode\WireCore\Foundation\View; -use NyonCode\WireCore\Foundation\Assets\AssetManager; +use NyonCode\LaravelPackageToolkit\Support\PackageAssets; /** * The floating-dropdown bundle URL, by the name a dozen partials already ask for it. * * The "Teleport + Floating UI" dropdown script is emitted by a partial that is * `@include`d many times per page (once per action-group dropdown, in both the - * desktop and mobile layouts), so the route + cache-busting mtime must resolve once - * per request rather than once per include (see - * architecture/plans/render-engine-htmlable-first.md §4). + * desktop and mobile layouts), so resolving it must not repeat the work once per + * include (see architecture/plans/render-engine-htmlable-first.md §4). * - * That memo now belongs to the canonical {@see AssetManager}, which owns the same - * concern for every package's bundles; this stays as a thin facade so the existing - * include sites keep working unchanged. It deliberately holds no cache of its own — - * a canonical owner is the resolve-once, and wrapping it in a second cache would - * only add a second thing to invalidate. + * That concern belongs to the toolkit's {@see PackageAssets}, which resolves every + * declared entry the same way and memoises the published URL one layer down in + * `PublishedAssets`. This stays as a thin facade so the existing include sites keep + * working unchanged, and so one place — not a dozen — knows that the entry key is a + * filename rather than the short id the old registry used. It deliberately holds no + * cache of its own: a canonical owner *is* the resolve-once, and a second cache would + * only be a second thing to invalidate. */ final class FloatingAssets { - public function __construct(private readonly AssetManager $assets) {} + /** The entry key, which under the toolkit is the shipped file. */ + private const ENTRY = 'wire-core-dropdown.js'; - /** URL of the pre-bundled dropdown script, cache-busted by the file's mtime. */ - public function url(): string + public function __construct(private readonly PackageAssets $assets) {} + + /** + * URL of the pre-bundled dropdown script, cache-busted by the file's mtime. + * + * `null` only where nothing is published, `public/` cannot be written *and* the + * package's own asset route could not be built — a combination the fallback in + * `WireCoreServiceProvider` exists to prevent. Callers emit a `') - ->not->toContain('type="module"') - ->not->toContain('defer') - ->not->toContain('data-navigate'); -}); - -it('carries the module, defer and navigation attributes it opts into', function () { - $html = Js::make('dropdown', '/does/not/exist.js') - ->module() - ->defer() - ->navigateTrack() - ->navigateOnce() - ->withPackage('wire-core') - ->toHtml(); - - // data-navigate-track makes Livewire reload the page when the tracked asset's - // query string changes — which our `?id=` supplies on every deploy. - expect($html) - ->toContain('type="module"') - ->toContain(' defer') - ->toContain('data-navigate-track') - ->toContain('data-navigate-once'); -}); - -it('memoises its markup', function () { - $asset = Js::make('dropdown', '/does/not/exist.js')->withPackage('wire-core'); - - expect($asset->toHtml())->toBe($asset->toHtml()); -}); - -it('is Htmlable, so a view renders it by echoing it', function () { - $asset = Js::make('dropdown', '/does/not/exist.js')->withPackage('wire-core'); - - expect(Blade::render('{{ $asset }}', ['asset' => $asset]))->toBe($asset->toHtml()); -}); - -it('keeps the declared asset unregistered when bound to a package', function () { - $declared = Js::make('dropdown', '/does/not/exist.js'); - $registered = $declared->withPackage('wire-core'); - - expect($declared->getPackage())->toBeNull() - ->and($registered->getPackage())->toBe('wire-core') - ->and($registered)->not->toBe($declared) - ->and($registered->getId())->toBe('dropdown'); -}); - -it('rebinding to another package rebuilds the URL', function () { - // The registering package names the route that serves the file, so the URL is - // not something a rebound copy may inherit. - Route::get('/wire-table/assets/{asset}.js', fn () => '')->name('wire-table.asset'); - - $asset = Js::make('records', '/does/not/exist.js')->withPackage('wire-core'); - $asset->toHtml(); - - expect($asset->withPackage('wire-table')->getUrl())->toContain('/wire-table/assets/records.js'); -}); - -it('is part of the always-emitted set unless it opts out', function () { - expect(Js::make('image', '/x.js')->isLoadedOnRequest())->toBeFalse() - ->and(Js::make('tiptap', '/x.js')->loadedOnRequest()->isLoadedOnRequest())->toBeTrue(); -}); diff --git a/packages/core/tests/Unit/Foundation/Assets/PublishedAssetsTest.php b/packages/core/tests/Unit/Foundation/Assets/PublishedAssetsTest.php index df287095..ad4ebca0 100644 --- a/packages/core/tests/Unit/Foundation/Assets/PublishedAssetsTest.php +++ b/packages/core/tests/Unit/Foundation/Assets/PublishedAssetsTest.php @@ -3,17 +3,16 @@ declare(strict_types=1); use Illuminate\Support\Facades\File; -use NyonCode\LaravelPackageToolkit\Support\PublishedAssets; -use NyonCode\WireCore\Foundation\Assets\AssetManager; -use NyonCode\WireCore\Foundation\Assets\Js; -use NyonCode\WireCore\WireCoreServiceProvider; +use NyonCode\LaravelPackageToolkit\Support\PackageAssets; +use NyonCode\WireCore\Foundation\Assets\Bundle; /** * wire-core's half of static delivery. The mirror itself — incremental copying, * atomic rename, walking files nobody resolved a URL for — belongs to - * `nyoncode/laravel-package-toolkit` and is covered by its suite. What is ours is - * what happens around it: the route fallback when `public/` cannot be written, and - * the warning when a copy is left behind. + * `nyoncode/laravel-package-toolkit` and is covered by its suite, as does the tag. + * What is ours is the declaration: that the bundles are `classic()` with no `defer`, + * and that a package whose `public/` cannot be written still serves them from the + * route ADR 0024 put behind the static files. */ beforeEach(function () { $root = sys_get_temp_dir().'/wire-published-'.bin2hex(random_bytes(6)); @@ -26,11 +25,23 @@ $this->app->usePublicPath($this->publicPath); - $this->bundle = $this->dist.'/wire-fixture-bundle.js'; - File::put($this->bundle, '/* bundle */'); + File::put($this->dist.'/wire-fixture-bundle.js', '/* bundle */'); $this->publishedPath = fn (string $relative): string => $this->publicPath.'/vendor/wire-fixture/'.$relative; + // Declare the fixture the way a provider would, straight onto the renderer: + // what is under test is the declaration's consequences, not provider boot. + $this->declare = function (string $package = 'wire-fixture'): void { + app(PackageAssets::class)->declare( + package: $package, + directory: $this->dist, + entries: [Bundle::make('wire-fixture-bundle.js')], + base: null, + mirrored: true, + fallback: Bundle::servedByRoute($package), + ); + }; + // A public/ that no user can create, root included: its parent is a file. File::put($root.'/not-a-directory', ''); $this->blockedPath = $root.'/not-a-directory/public'; @@ -39,18 +50,18 @@ }); it('emits the mirrored file rather than the package route', function () { - $url = Js::make('bundle', $this->bundle)->withPackage('wire-fixture')->getUrl(); + ($this->declare)(); - expect($url)->toBe( + expect(app(PackageAssets::class)->url('wire-fixture', 'wire-fixture-bundle.js'))->toBe( asset('vendor/wire-fixture/wire-fixture-bundle.js') .'?id='.filemtime(($this->publishedPath)('wire-fixture-bundle.js')) ); }); it('keeps the cache-buster, so data-navigate-track still reloads on an upgrade', function () { - $html = Js::make('bundle', $this->bundle)->withPackage('wire-fixture')->navigateTrack()->toHtml(); + ($this->declare)(); - expect($html) + expect(app(PackageAssets::class)->scripts('wire-fixture')->toHtml()) ->toContain('data-navigate-track') ->toContain('vendor/wire-fixture/wire-fixture-bundle.js') ->toMatch('/\?id=\d+/'); @@ -65,116 +76,39 @@ // when it runs as root in a container, and root walks straight through modes. $this->app->usePublicPath($this->blockedPath); - $url = Js::make('dropdown', $this->bundle)->withPackage('wire-core')->getUrl(); - - expect($url)->toContain('/wire-core/assets/dropdown.js') - ->and($this->blockedPath)->not->toBeDirectory(); -}); - -it('prefers a copy the mirror could not refresh over a route that may be unreachable', function () { - // The deployment that ends up here is the one whose nginx answers `.js` from - // `try_files $uri =404` — falling back would trade a release-old bundle for none. - // - // The state is forced through the once-per-request sync: resolve first, then age - // the copy. In production it is reached when `public/` cannot be written back. - $asset = Js::make('bundle', $this->bundle)->withPackage('wire-fixture'); - $asset->getUrl(); - - touch(($this->publishedPath)('wire-fixture-bundle.js'), filemtime($this->bundle) - 10); + // wire-core, because the fallback resolves that package's real asset route. + ($this->declare)('wire-core'); - expect(Js::make('bundle', $this->bundle)->withPackage('wire-fixture')->getUrl()) - ->toContain('vendor/wire-fixture/wire-fixture-bundle.js') - ->and($asset->isStale())->toBeTrue(); + expect(app(PackageAssets::class)->url('wire-core', 'wire-fixture-bundle.js')) + ->toContain('/wire-core/assets/fixture-bundle.js'); }); -it('names the stale bundles and the command that fixes them', function (bool $debug) { - // Not gated on app.debug: reaching this state means the mirror could not write, - // which happens in production, where a debug-only warning would never be seen. - config()->set('app.debug', $debug); - - $manager = new AssetManager; - $manager->register([Js::make('bundle', $this->bundle)], 'wire-fixture'); - - // Mirror once, then age the copy behind the per-request memo. - $manager->renderScripts(); - touch(($this->publishedPath)('wire-fixture-bundle.js'), filemtime($this->bundle) - 10); - - $fresh = new AssetManager; - $fresh->register([Js::make('bundle', $this->bundle)], 'wire-fixture'); - - expect($fresh->renderScripts()->toHtml()) - ->toContain('console.warn') - ->toContain('wire-fixture/bundle') - ->toContain('vendor:publish --tag=laravel-assets'); -})->with([true, false]); - -it('does not warn on the first render after an upgrade, which the mirror repairs', function () { - // The tags are built before staleness is judged, because resolving a URL is what - // runs the mirror. Judging first would find every copy out of date on the request - // that is about to replace them, and warn about a state it repaired one line later. - File::ensureDirectoryExists(dirname(($this->publishedPath)('wire-fixture-bundle.js'))); - File::put(($this->publishedPath)('wire-fixture-bundle.js'), '/* last release */'); - touch(($this->publishedPath)('wire-fixture-bundle.js'), filemtime($this->bundle) - 10); - - $manager = new AssetManager; - $manager->register([Js::make('bundle', $this->bundle)], 'wire-fixture'); - - expect($manager->renderScripts()->toHtml())->not->toContain('console.warn') - ->and(file_get_contents(($this->publishedPath)('wire-fixture-bundle.js')))->toBe('/* bundle */'); -}); - -it('says nothing when the mirror is current', function () { - $manager = new AssetManager; - $manager->register([Js::make('bundle', $this->bundle)], 'wire-fixture'); - - expect($manager->renderScripts()->toHtml())->not->toContain('console.warn'); -}); - -it('never calls a remote or unregistered bundle stale', function () { - // Neither has a shipped file to compare a published copy against. - expect(Js::make('cdn', 'https://cdn.example.test/x.js')->withPackage('wire-core')->isStale())->toBeFalse() - ->and(Js::make('bundle', $this->bundle)->isStale())->toBeFalse(); -}); - -it('forgets resolved URLs and rendered tags, so a long-lived worker can re-resolve', function () { - // Octane: the manager is a singleton, so its memos would otherwise outlive the - // deploy that changed the files and the worker would keep emitting last release's - // `?id=` — the one thing data-navigate-track exists to notice. The - // RequestTerminated hook calls flushUrls(); this proves it empties both memos. - // - // End to end the flush also needs `PublishedAssets::flush()`, one layer down — - // without it the re-resolve asks the toolkit and gets its memo back. That landed - // in laravel-package-toolkit 2.3.1, and the constraint is `^2.4.0`, so the hook - // calls it outright rather than probing for it. - $manager = new AssetManager; - $manager->register([Js::make('bundle', $this->bundle)], 'wire-fixture'); - - $rendered = $manager->renderScripts(); - $asset = $manager->get('wire-fixture', 'bundle'); - - expect($manager->renderScripts())->toBe($rendered); - - $manager->flushUrls(); +it('keeps the tag rather than dropping it when nothing is published', function () { + // The failure this exists to prevent is silent: without a fallback the renderer + // emits no tag at all, and the page loses its behaviour with nothing to see. + $this->app->usePublicPath($this->blockedPath); - expect($manager->renderScripts())->not->toBe($rendered) - ->and($manager->get('wire-fixture', 'bundle'))->not->toBe($asset); + ($this->declare)('wire-core'); + + // The tag, not `resolution()`: the report asks whether the mirror *could* write + // by walking up to the nearest existing ancestor, and this fixture blocks the + // path with a regular file rather than with permissions — so the walk steps over + // the blockage into a writable ancestor and reports `shipped`. Harmless here + // (it only drives diagnostics, and the tag below is what the page gets) but it + // is why the assertion is on delivery. + expect(app(PackageAssets::class)->scripts('wire-core')->toHtml()) + ->toContain(' +@if(is_file($liveAssetFile)) +@packageScripts('wire-table', 'wire-table-live.js') @else {{-- The x-data on the polling wrapper references the factory either way, and that wrapper contains the entire table — a dangling reference would take Alpine diff --git a/packages/table/resources/views/tables/partials/record-actions-assets.blade.php b/packages/table/resources/views/tables/partials/record-actions-assets.blade.php index f636fc39..6855a3a4 100644 --- a/packages/table/resources/views/tables/partials/record-actions-assets.blade.php +++ b/packages/table/resources/views/tables/partials/record-actions-assets.blade.php @@ -1,14 +1,11 @@ -@php - // The URL (route + cache-busting mtime) is owned and memoised by the canonical - // AssetManager, which this package's provider registers the bundle with. - // Recomputing it here would be a second resolver for the same concern. - $assetUrl = app(\NyonCode\WireCore\Foundation\Assets\AssetManager::class)->url('wire-table', 'records'); -@endphp - {{-- Pre-bundled record-action controller (wireRecordActions). Loaded through Livewire's @assets directive so the script registers once and also runs when the table renders inside a Livewire-loaded modal, where a DOM-morphed - +@packageScripts('wire-table', 'wire-table-records.js') @endassets diff --git a/packages/table/resources/views/tables/partials/selection-assets.blade.php b/packages/table/resources/views/tables/partials/selection-assets.blade.php index c49705df..cdb17ea4 100644 --- a/packages/table/resources/views/tables/partials/selection-assets.blade.php +++ b/packages/table/resources/views/tables/partials/selection-assets.blade.php @@ -1,12 +1,9 @@ @php - // The URL (route + cache-busting mtime) is owned and memoised by the canonical - // AssetManager, which this package's provider registers the bundle with. - // The path is still needed here to choose the branch below, and to read the - // source for the fallback when the compiled bundle is absent. + // The tag itself belongs to the toolkit's renderer (`@packageScripts` below), + // which owns delivery and the attributes the declaration carries. The path is + // still needed here to choose the branch, and to read the source for the + // fallback when the compiled bundle is absent. $selectionAssetFile = \NyonCode\WireTable\WireTableServiceProvider::ASSETS_PATH.'/wire-table-selection.js'; - $selectionAssetUrl = is_file($selectionAssetFile) - ? app(\NyonCode\WireCore\Foundation\Assets\AssetManager::class)->url('wire-table', 'selection') - : null; @endphp {{-- Pre-bundled selection component (wireRecordSelection). Loaded through @@ -14,8 +11,8 @@ the table renders inside a Livewire-loaded modal, where a DOM-morphed +@if(is_file($selectionAssetFile)) +@packageScripts('wire-table', 'wire-table-selection.js') @else {{-- The x-data on the table wrapper references the factory either way, and the wrapper owns search, filters, the bulk bar, pagination, the mobile cards diff --git a/packages/table/src/WireTableServiceProvider.php b/packages/table/src/WireTableServiceProvider.php index daffb23f..dd426ad8 100644 --- a/packages/table/src/WireTableServiceProvider.php +++ b/packages/table/src/WireTableServiceProvider.php @@ -10,8 +10,7 @@ use NyonCode\LaravelPackageToolkit\Packager; use NyonCode\LaravelPackageToolkit\PackageServiceProvider; use NyonCode\WireCore\Actions\Action; -use NyonCode\WireCore\Foundation\Assets\AssetManager; -use NyonCode\WireCore\Foundation\Assets\Js; +use NyonCode\WireCore\Foundation\Assets\Bundle; use NyonCode\WireTable\Livewire\TableStateSynthesizer; use NyonCode\WireTable\Support\RecordAction; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -37,11 +36,15 @@ public function configure(Packager $packager): void $this->registerRecordActionMacros(); $this->registerAssetRoutes(); - $this->registerAssets(); }) ->hasConfig() ->hasViews() - ->hasAssets('dist') + ->hasAssets('dist', entries: [ + Bundle::make('wire-table-records.js'), + Bundle::make('wire-table-selection.js'), + Bundle::make('wire-table-live.js'), + ]) + ->hasAssetFallback(Bundle::servedByRoute('wire-table')) ->hasMigrations() ->hasTranslations() ->hasAbout() @@ -104,27 +107,6 @@ protected function registerAssetRoutes(): void ->name('wire-table.asset'); } - /** - * Declare the table's browser bundles with the canonical AssetManager, so an app - * that renders `@wireStackScripts` in its layout carries `wireRecordActions` and - * `wireRecordSelection` on every page — including one with no table, which is - * the page a `wire:navigate` visit to a table is made *from*. - */ - protected function registerAssets(): void - { - app(AssetManager::class)->register([ - Js::make('records', self::ASSETS_PATH.'/wire-table-records.js') - ->navigateTrack() - ->navigateOnce(), - Js::make('selection', self::ASSETS_PATH.'/wire-table-selection.js') - ->navigateTrack() - ->navigateOnce(), - Js::make('live', self::ASSETS_PATH.'/wire-table-live.js') - ->navigateTrack() - ->navigateOnce(), - ], 'wire-table'); - } - /** * Extra rows for this package's `php artisan about` section (the toolkit * already prepends "Version"). Values are closures so config resolves at diff --git a/packages/table/tests/Feature/WireStackScriptsTest.php b/packages/table/tests/Feature/WireStackScriptsTest.php index 4d3784c8..f5735cf5 100644 --- a/packages/table/tests/Feature/WireStackScriptsTest.php +++ b/packages/table/tests/Feature/WireStackScriptsTest.php @@ -47,9 +47,15 @@ // The per-surface @assets partials still exist for apps without the directive; // the directive must not turn into a second copy of them for apps with it. // - // Six, since the clipboard controller joined the live-broadcast bridge. Both - // ship on every page for the same reason the others do: the behaviour a table's - // markup reaches for has to exist before a wire:navigate visit renders the - // table, and the page that visit is made *from* may have no table on it at all. - expect(substr_count(Blade::render('@wireStackScripts'), 'toBe(6); + // Seven: dropdown, copy and chart from core, image from forms, records, + // selection and live from the table. They ship on every page for the same + // reason: the behaviour a table's markup reaches for has to exist before a + // wire:navigate visit renders the table, and the page that visit is made + // *from* may have no table on it at all. + // + // Chart is the one that moved. It was held back as an optional heavy body, + // which it is not — 671 bytes of Alpine registrar around the app's own + // Chart.js — and delivering a registrar late is precisely what ADR 0024 + // forbids. + expect(substr_count(Blade::render('@wireStackScripts'), 'toBe(7); }); From 969310fa3857247be178c29a1461d6be28c66954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nykl=C3=AD=C4=8Dek?= <60318239+ONyklicek@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:14:51 +0200 Subject: [PATCH 27/30] Add placeholder text color --- packages/forms/resources/views/components/text-input.blade.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/forms/resources/views/components/text-input.blade.php b/packages/forms/resources/views/components/text-input.blade.php index 6ccebc1a..0127436d 100644 --- a/packages/forms/resources/views/components/text-input.blade.php +++ b/packages/forms/resources/views/components/text-input.blade.php @@ -91,6 +91,7 @@ @class([ 'block w-full rounded-md border-gray-300 shadow-sm', 'focus:border-primary-500 focus:ring-primary-500', + 'placeholder:text-gray-400 dark:placeholder:text-gray-500', 'hover:border-gray-400 dark:hover:border-gray-500 transition-colors duration-150', 'dark:bg-gray-800 dark:border-gray-600 dark:text-white text-sm', 'border-red-500 focus:border-red-500 focus:ring-red-500' => $errors->has($field->getStatePath()), From 62129ba083df0ed555559574eae808421547d790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Nykl=C3=AD=C4=8Dek?= <60318239+ONyklicek@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:58:11 +0200 Subject: [PATCH 28/30] Configure a Select's option modals like every other modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create/edit option modals were rendered from literals in the partial that hosts them: the heading came from a narrow setter, `width: 'md'` was hardcoded, the cancel label was read straight out of the translation file, and everything else was unreachable. An option form holding more than a name field was squeezed into a dialog narrower than the form inside it. Both modals now carry the canonical Modals\Modal config object — the same one the action modals use — and the partial projects it onto the Html\Modal render object the way actions/modal-host.blade.php does. Heading, description, icon, width, close behaviour, max height, sticky chrome, full-screen-on-mobile and both button labels all work. createOptionModalHeading() and the new createOptionModalWidth() stay as shorthands writing into that same object rather than a parallel bag: two owners for one modal is exactly why the cancel label was untouchable while the heading was settable. The modal's id and its wire:model/close action are deliberately not configurable — both option modals can be mounted at once and Livewire morphs them by that key. An unconfigured option modal now honours wire-core.modals.default_width, where it used to be md regardless. --- CHANGELOG.md | 1 + docs/cs/forms/fields/select.md | 45 ++++++- docs/forms/fields/select.md | 45 ++++++- .../boost/docs/forms/custom-fields.md | 33 ++--- .../boost/docs/forms/fields/select.md | 45 ++++++- .../boost/guidelines/wire-forms.blade.php | 8 +- .../select-option-modal-footer.blade.php | 7 +- .../partials/select-option-modals.blade.php | 46 ++++++- packages/forms/src/Components/Select.php | 114 +++++++++++++++++- .../tests/Feature/SelectCreateOptionTest.php | 85 +++++++++++++ .../tests/Unit/Components/SelectTest.php | 72 +++++++++++ 11 files changed, 467 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d17f5b4..72a898c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the Wire ecosystem will be documented in this file. ### Added - **The TipTap editor speaks Czech, and opens on a pre-formatted document — `->default()`.** Its toolbar tooltips were bare `__('Bold')` keys, which only an *app-level* translation file could ever answer: a Czech app shipped a fully translated form with an English editor bolted into the middle of it, and the two strings that live inside the JS bundle — the link and image `prompt()` titles — could not be translated at all. All of it now resolves from the package's own vocabulary (`wire-forms::fields.editor.*`, `en` + `cs`), including the prompt titles, which are read in PHP and handed to the editor through its Alpine config rather than being hardcoded in the bundle; the group is named `editor` rather than `tiptap` because **RichEditor and MarkdownEditor title their toolbars from the very same keys** — one vocabulary for all three editors, so they read alike in every locale and a reworded button is reworded once. RichEditor's link prompt moved from `prompt('{{ __('Enter URL') }}')` to `@js()`, which hex-escapes both quote characters: the old form rendered an apostrophe as `'`, and a locale whose wording contains one would have closed the JS string and, with it, the `x-data` attribute around it. Headings read as *Heading 2* / *Nadpis 2* rather than `H2` — the glyph on the button stays `H1`/`H2`/`H3` in every locale, since those are symbols, not words. Separately, a starting template is now the canonical `->default()` and **not** a second editor-only method: the form runtime already seeds it into the state bag, and the field additionally hands it to the editor, which applies it when the bound value is empty and pushes the parsed document back into Livewire — so a host that never seeded (a `null` column, a hand-bound property) still opens on the template, and saving an untouched form stores it rather than nothing. The default is markup, so `'

Zápis z porady

Nějaký text

'` arrives formatted; under `->outputJson()` it may be a JSON document string *or* the same HTML, which is where the old code dropped it — a non-JSON value was parsed with a `catch { return {} }` and became an empty editor. Re-opening a document the user deliberately cleared does not bring the default back: an emptied editor stores `

`, not `''`. Browser-verified by `workbench/scripts/verify-tiptap-split.mjs` (14/14) against a new `/previews/field-tiptap-default`, which reads the seeded document out of Livewire's state, not just off the screen. See `docs/forms/fields/tiptap-editor.md`. - **JS bundles are served as static files out of `public/vendor`, and get there by themselves.** Serving a bundle from a package route only works when the request reaches PHP, and a very common nginx layout answers `.js` from a `try_files $uri =404` block that never forwards it — the same block 404s Livewire's own `/livewire/livewire.js`. On shared hosting that block is frequently not the application's to change, so a delivery mode whose correctness depends on a vhost the app cannot edit is not a delivery mode. It is now files: the first page render after a deploy mirrors each package's `dist/` into `public/vendor/` and `@wireStackScripts` emits those paths. No command, no composer hook, no config key — and nothing for an app that already worked to do. The mirror is **incremental** (only a file missing or older than the shipped one is copied, so steady state is a handful of `stat` calls and zero writes, and an upgrade is one copy per changed bundle on one request), **atomic** (copies land through a temp file and `rename()`, so a browser fetching mid-copy never gets a truncated bundle — which would be a syntax error taking every controller in it down), **whole-directory** (TipTap's entry imports `./chunk-.js`, which the browser fetches itself and PHP is never asked to resolve, so a mirror driven only by registered bundles would break the editor), and **lazy** rather than booted (mirroring from `boot()` would put a directory walk on every queue job and API route that will never emit a `