(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:

  1. Early module in the head — browser starts downloading the library ASAP.
  2. Globalizer moduleimports the library, assigns window.Sortable, signals the registry.
  3. Your code — waits with VMInit.waitForGlobal('Sortable') (or uses window.Sortable after that), then calls new 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”.
  • import inside 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 calls new Sortable on undefined.

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') before new Sortable.
  • Mark containers with data-vmjs-sortable / vm-js-sortable when 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.Sortable exists at parse time of an inline script in the head — race.
  • Do not wait only on the string 'Sortable Ready' via waitForGlobal — 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)