(written by Grok and reviewed by Max Milbers)
Audience: Extension developers, template builders, anyone adding reorderable lists in VirtueMart 5
Library: SortableJS (MIT) under components/com_virtuemart/assets/js/sortable/
This is not a full SortableJS API manual — it is why we use it, how VirtueMart loads it, and how you plug in cleanly.
1. Why drag-and-drop still matters in a shop
Merchants reorder media, custom fields, related items, price rows, country lists — every day. That used to mean jQuery UI Sortable (or similar), another dependency chain, and fragile “init after AJAX” scripts.
VirtueMart 5 standardises on SortableJS: small, maintained, no jQuery, works with plain DOM lists and tables. Core admin already uses it for media galleries, product edit lists, and admin list bodies. You get the same library, the same load path, and the same wait pattern as the core.
Product pitch in one line: reorderable UI without inventing your own drag stack — and without racing module load order.
2. Where it lives
| File | Role |
|---|---|
sortable/sortable.core.esm.min.js |
SortableJS library (ES module) |
sortable/sortable-global.js |
Globalizer: import + window.Sortable + VMInit signal |
sortable/LICENSE |
MIT — SortableJS contributors |
Loaded for modern admin via vmJsApi::loadModernAdminJs() / admin UI bootstrap
(and anywhere else that opts into the same dual load).
3. The dual-load pattern (the important part for 3rd parties)
Sortable is the textbook example of VirtueMart’s header + globalizer approach:
- Early module in the head — browser starts downloading the library ASAP.
- Globalizer module —
imports the library, assignswindow.Sortable, signals the registry. - Your code — waits with
VMInit.waitForGlobal('Sortable')(or useswindow.Sortableafter that), then callsnew Sortable(...).
Core PHP (simplified):
// Early download of the library module
vmJsApi::addvScriptModule('sortable/sortable.core.esm.min');
// Globalizer: import + window.Sortable + "Sortable Ready" signal
vmJsApi::addvScriptModule('sortable/sortable-global');
What the globalizer does (conceptually):
import Sortable from './sortable.core.esm.min.js';
window.Sortable = Sortable;
if (typeof VMInit !== 'undefined' && typeof VMInit.add === 'function') {
VMInit.add('Sortable Ready', () => {}, 520);
}
Why both?
- Header / early module → parallel download, less “library arrives too late”.
importinside the globalizer → execution only after the dependency graph is ready (same idea as other VM modules: ready order without guessing script tags).window.Sortable→ legacy snippets and modules can share one global.waitForGlobal('Sortable')→ your init never callsnew Sortableonundefined.
Full story of the init queue: VMInit registry Technics article.
Note: Wait for the global name Sortable (the property on window), not the registry label Sortable Ready. The Ready entry is an internal queue marker; availability is window.Sortable.
4. How core uses it (so you can match the style)
VirtueMart attaches Sortable to containers and, on drag end, rewrites ordering inputs so a normal form save persists the order. Typical hooks:
| Hook | Meaning |
|---|---|
[data-vmjs-sortable] |
Admin list / generic sortable root (vmadmin init) |
.vm-js-sortable |
Product / custom-field style lists |
.vm-sortable-handle (and legacy handle class) |
Drag handle — only this area starts a drag |
input.ordering / .ordering / .order |
Fields renumbered in onEnd |
Examples already in core:
- Media gallery — reorder thumbs in
#vm-js-medias-container, update.ordering. - Product edit — sortable custom-field / related blocks via
.vm-js-sortable. - Admin lists —
[data-vmjs-sortable]on table bodies / lists with handles. - Price rows — Sortable on a tbody with a dedicated handle class.
Simplified core-style init:
VMInit.waitForGlobal('Sortable', function (Sortable) {
if (!Sortable) return;
document.querySelectorAll('[data-vmjs-sortable], .vm-js-sortable').forEach(function (el) {
if (el._vmSortableInit) return;
el._vmSortableInit = true;
new Sortable(el, {
handle: '.vm-sortable-handle',
animation: 80,
draggable: el.tagName === 'TBODY' ? 'tr' : undefined,
onEnd: function () {
el.querySelectorAll('input.ordering, .ordering').forEach(function (inp, i) {
inp.value = i;
});
}
});
});
});
Idempotent flags (_vmSortableInit / dataset) matter: with
VMInit reInit,
your code may run again after AJAX — do not stack multiple Sortable instances on the same node.
5. Quick start for extensions
5.1 Ensure the library is loaded
If you are on a modern admin page that already calls loadModernAdminJs() / admin UI start,
Sortable is usually already there. From a plugin or custom view:
if (class_exists('vmJsApi')) {
// Prefer the same dual load as core
vmJsApi::addvScriptModule('sortable/sortable.core.esm.min');
vmJsApi::addvScriptModule('sortable/sortable-global');
}
(Or call the shared helper that loads Sortable + AutoComplete + Mustache together, when that helper is available in your context — same dual pattern underneath.)
5.2 Markup
<ul class="vm-js-sortable" data-vmjs-sortable>
<li>
<span class="vm-sortable-handle">☰</span>
Item A
<input type="hidden" class="ordering" name="ordering[]" value="0">
</li>
<li>
<span class="vm-sortable-handle">☰</span>
Item B
<input type="hidden" class="ordering" name="ordering[]" value="1">
</li>
</ul>
5.3 Register init (priority ≥ 550 for plugins)
if (typeof VMInit !== 'undefined' && typeof VMInit.add === 'function') {
VMInit.add('MyPlugin Sortable', function () {
VMInit.waitForGlobal('Sortable', function (Sortable) {
if (!Sortable) return;
var el = document.querySelector('#my-plugin-list');
if (!el || el._mySortable) return;
el._mySortable = true;
new Sortable(el, {
handle: '.vm-sortable-handle',
animation: 80,
onEnd: function () {
el.querySelectorAll('.ordering').forEach(function (inp, i) {
inp.value = i;
});
}
});
});
}, 550, { reInit: true, retry: 3 });
}
That is the whole VirtueMart recipe: load dual → wait for global → new Sortable → write ordering → survive reInit.
6. Features we care about (not the whole SortableJS surface)
| Option / idea | Typical VM use |
|---|---|
handle |
Restrict drag to a grip icon so links/buttons stay clickable |
animation |
Short ms value (core often uses ~80–150) |
draggable |
On TBODY, limit to tr |
onEnd |
Renumber .ordering / hidden fields for the next form save |
| Idempotent init | Flag on the element; required with AJAX / reInit: true |
Group, multi-drag, swap plugins, and deep SortableJS options: see the official manual (link below). Core deliberately stays on a small, boring subset.
7. Do’s and don’ts
Do
- Use the dual load (core ESM +
sortable-global). - Wait with
VMInit.waitForGlobal('Sortable')beforenew Sortable. - Mark containers with
data-vmjs-sortable/vm-js-sortablewhen you want to stay consistent with core selectors. - Persist order through normal form fields — Sortable only moves DOM; PHP still saves.
- Keep init idempotent under
reInit: true.
Don’t
- Do not ship a second jQuery UI Sortable “just for your plugin” on the same page if core already provides SortableJS.
- Do not assume
window.Sortableexists at parse time of an inline script in the head — race. - Do not wait only on the string
'Sortable Ready'viawaitForGlobal— that is not the window property name. - Do not re-attach Sortable on every AJAX without destroying or skipping already-initialised nodes.
8. What this is not
- Not a CMS-wide drag framework — it is list/container reordering for shop UIs.
- Not automatic server sort — you still save ordering fields (or send your own AJAX).
- Not a full SortableJS course — advanced plugins and options live upstream.
9. Official SortableJS documentation
Full API, options, and plugins:
SortableJS — official site / docs
Repository: github.com/SortableJS/Sortable
SortableJS is MIT-licensed. VirtueMart ships the core ESM build and a thin globalizer so the
shop stack can treat window.Sortable as a shared dependency.
Related Technics:
- VMInit registry — priorities,
waitForGlobal,reInit - Why Alpine.js — HTML-first UI (pairs with Sortable for “feel”, not a replacement for drag ordering)