Integration — Asset Bundles, Webpack, Craft.* Classes, Form Widgets
Source
src/web/assets/garnish/GarnishAsset.phpsrc/web/assets/garnish/webpack.config.jssrc/web/assets/garnish/src/index.jssrc/web/assets/cp/CpAsset.phppackages/craftcms-webpack/index.js(externals config)src/web/assets/garnish/src/NiceText.jssrc/web/assets/garnish/src/CheckboxSelect.jssrc/web/assets/garnish/src/MixedInput.jssrc/web/assets/garnish/src/MultiFunctionBtn.js
Common Pitfalls
- Loading element index JS via Vite (
craft.myPlugin.register()) —nystudio107/craft-plugin-viteaddstype="module"to all<script>tags. Module scripts execute deferred, which meansCraft.registerElementIndexClass()runs afterCraft.createElementIndex()in the{% js %}block (POS_READY). The class isn't registered in time, so the custom index never loads. Craft core and Commerce both load element index JS as regular scripts through Yii2 asset bundles — never as modules. Use the regular AssetBundle pattern for element index classes (see Element Index JS Loading below). Vite module loading is fine for field type JS, settings pages, and anything that runs on DOMContentLoaded or later. - Bundling Garnish into a plugin's webpack output — use
import Garnish from 'garnishjs'which resolves to the global via externals. - Requiring Garnish before
CpAssetis loaded — if your asset bundle doesn't depend onCpAsset(orGarnishAsset), Garnish won't be available. - Using
Craft.MyClass = function() {}instead ofGarnish.Base.extend()— loses event system, listener management, settings, and destroy lifecycle. - Calling
Craft.initUiElements()on content without context — it re-initializes all UI widgets in the container, which can double-instantiate. - Giving
nameattributes to inputs in a panel injected into an element edit screen — the surrounding element form serializes them and your values are silently dropped on save (or collide with something Craft reads). Id-only markup, read by id in your own JS, plus an Enter-key guard so the host form doesn't submit. See Panels Injected Into Another Form. - Reusing Craft's reserved CP DOM IDs (
#notifications,#content,#tabs,#sidebar,#details,#main, etc.) on plugin markup —Craft.CPcaches chrome refs via$('#foo')during init and takes the first match in DOM order, so a same-named plugin element silently hijacks notification toasts, ARIA masking, or layout wiring. Pick feature-specific names for tab keys, slideout/HUD roots, and any container you give anid. Seecraftcmsskillreferences/cp.md(Reserved DOM IDs) for the full list.
Table of Contents
- Loading Sequence
- GarnishAsset PHP Bundle
- Webpack Configuration
- Plugin Asset Bundles
- Craft.* Class Pattern
- Twig JavaScript Blocks
- Panels Injected Into Another Form
- Custom Field Input JS & Dynamic Re-init
- Element Index JS Loading
- Form Widgets
Loading Sequence
1. <head>
└── CpAsset registered
├── depends on GarnishAsset
│ ├── depends on JqueryAsset
│ ├── depends on VelocityAsset
│ ├── depends on JqueryTouchEventsAsset
│ └── depends on ElementResizeDetectorAsset
└── registers cp.js, cp.css
2. Asset load order (guaranteed by Yii2 dependency resolution):
jquery.js
→ jquery-touch-events.js
→ velocity.js
→ element-resize-detector.js
→ garnish.js ← window.Garnish available
→ cp.js ← Craft.CP, Craft.* classes
→ plugin assets ← your code
3. Initialization:
garnish/src/index.js creates singletons:
Garnish.uiLayerManager = new Garnish.UiLayerManager()
Garnish.escManager = new Garnish.EscManager()
cp.js initializes:
Craft.cp = new Craft.CP()GarnishAsset PHP Bundle
// craft\web\assets\garnish\GarnishAsset
class GarnishAsset extends AssetBundle
{
public function init(): void
{
$this->sourcePath = __DIR__ . '/dist';
$this->depends = [
ElementResizeDetectorAsset::class,
JqueryAsset::class,
JqueryTouchEventsAsset::class,
VelocityAsset::class,
];
$this->js = ['garnish.js'];
parent::init();
}
}The compiled dist/garnish.js (134KB) is a single bundle containing all Garnish classes.
Webpack Configuration
Garnish's Own Build
// src/web/assets/garnish/webpack.config.js
module.exports = getConfig({
context: __dirname,
config: {
entry: { garnish: './index.js' },
module: {
rules: [{
test: require.resolve('./src/index.js'),
loader: 'expose-loader',
options: {
exposes: [{
globalName: 'Garnish',
moduleLocalName: 'default',
}],
},
}],
},
},
});expose-loader makes the default export of index.js available as window.Garnish.
Externals for Plugin Assets
Craft's shared webpack config (@craftcms/webpack) configures externals:
externals: {
'garnishjs': 'Garnish',
'jquery': 'jQuery',
'axios': 'axios',
}This means in any Craft asset bundle built with @craftcms/webpack:
// This import resolves to window.Garnish (no bundling)
import Garnish from 'garnishjs';
// This import also resolves to a global
import $ from 'jquery';There is no craft external. Craft is an ambient global — available on window once the CpAsset/CP runtime has loaded — so you do not import it. Craft core code references it directly and marks it for the linter with a /** global: Craft */ comment rather than importing it:
/** global: Craft */
Craft.postActionRequest('my-plugin/do-thing', data);Plugin Webpack Setup
// your-plugin/src/web/assets/myasset/webpack.config.js
const {getConfig} = require('@craftcms/webpack');
module.exports = getConfig({
context: __dirname,
config: {
entry: { myasset: './src/MyAsset.js' },
},
});// MyAsset.js
import Garnish from 'garnishjs';
import $ from 'jquery';
Craft.MyWidget = Garnish.Base.extend({
init: function (element, settings) { ... },
});Plugin Asset Bundles
Your PHP asset bundle must depend on CpAsset (which includes GarnishAsset):
use craft\web\assets\cp\CpAsset;
class MyPluginAsset extends AssetBundle
{
public function init(): void
{
$this->sourcePath = __DIR__ . '/dist';
$this->depends = [CpAsset::class];
$this->js = ['myasset.js'];
parent::init();
}
}Register it from a controller or template:
// In a controller
$this->getView()->registerAssetBundle(MyPluginAsset::class);
// In Twig
{% do view.registerAssetBundle('myplugin\\web\\assets\\MyPluginAsset') %}Craft.* Class Pattern
All Craft CP JavaScript classes extend Garnish.Base:
Craft.CP = Garnish.Base.extend({ ... });
Craft.ElementEditor = Garnish.Base.extend({ ... });
Craft.Slideout = Garnish.Base.extend({ ... });
Craft.AuthManager = Garnish.Base.extend({ ... });
Craft.ColorInput = Garnish.Base.extend({ ... });
Craft.FieldToggle = Garnish.Base.extend({ ... });Pattern for plugin classes:
import Garnish from 'garnishjs';
// Define on the Craft namespace so it's accessible from Twig {% js %} blocks
Craft.MyPlugin = {};
Craft.MyPlugin.FieldEditor = Garnish.Base.extend({
$container: null,
dragSort: null,
init: function (container, settings) {
this.$container = $(container);
this.setSettings(settings, Craft.MyPlugin.FieldEditor.defaults);
// Use Garnish widgets
this.dragSort = new Garnish.DragSort(
this.$container.find('.item'),
{
axis: Garnish.Y_AXIS,
handle: '.move',
onSortChange: this.handleSortChange.bind(this),
}
);
// Use Garnish listeners (auto-cleanup on destroy)
this.addListener(this.$container, 'activate', 'handleActivate');
},
handleActivate: function (ev) {
// Show a HUD for inline editing
new Garnish.HUD(ev.currentTarget, this.getEditorHtml(), {
onSubmit: this.handleSave.bind(this),
});
},
destroy: function () {
if (this.dragSort) {
this.dragSort.destroy();
}
this.base();
},
}, {
defaults: {
maxItems: null,
},
});Twig JavaScript Blocks
In CP templates, use {% js %} blocks to write JavaScript that uses Garnish:
{% js %}
// Garnish is already available as window.Garnish
new Garnish.DragSort($('#{{ id|namespaceInputId }}').find('.items .item'), {
axis: Garnish.Y_AXIS,
handle: '.move.icon',
onSortChange: function () {
// reorder logic
},
});
// Use activate event for keyboard accessibility
$('#{{ handle|namespaceInputId }}').on('activate', function () {
// handle activation
});
// Use key constants
$('#my-input').on('keydown', function (ev) {
if (ev.keyCode === Garnish.RETURN_KEY && Garnish.isCtrlKeyPressed(ev)) {
// Ctrl+Enter
}
});
{% endjs %}Common patterns in Twig:
{# Disclosure menu (Garnish auto-initializes via Craft.initUiElements) #}
<button aria-controls="menu-{{ id }}" data-disclosure-trigger>Actions</button>
<div id="menu-{{ id }}" class="menu menu--disclosure">
<ul class="padded">
<li><button class="menu-item" type="button">Edit</button></li>
</ul>
</div>
{# Menu button (Craft.initUiElements auto-initializes .menubtn) #}
<button class="btn menubtn">Options</button>
<div class="menu">
<ul>
<li><a>Option A</a></li>
<li><a>Option B</a></li>
</ul>
</div>Craft.initUiElements($container) initializes the Craft/Garnish widgets inside $container: .menubtn / [data-disclosure-trigger] (menus, run last as they mutate the DOM), .checkbox-select, .fieldtoggle, .lightswitch, .nicetext, date/time inputs (.datetimewrapper), .formsubmit, .info icons, and expandable buttons. Always pass a container so you don't re-init the whole page (double-instantiation). There is no initUi jQuery event to listen for — Craft.initUiElements($container) is the idempotent re-init entry point.
Panels Injected Into Another Form
A plugin panel added to an element edit screen (via EVENT_DEFINE_SIDEBAR_HTML, a CP template hook, or any injected markup) lives inside Craft's element form. That form owns submission, and it will happily swallow your panel unless the panel is built to stay out of its way.
Three rules, and they only make sense together:
1. The panel's inputs carry id but no name. Anything with a name inside the host form gets serialized and posted to Craft's element save action — where your plugin's key is ignored (value silently lost on every save) or, if it collides, written somewhere you didn't intend. Without a name, the host form can't see the field at all, which is exactly what you want when your plugin saves the data itself. The markup side of this is in the craftcms skill's cp-ui-patterns.md (Element Edit Screen).
2. Your JS reads the values by id and posts them itself.
3. Guard the Enter key, or a visitor typing in your text input and pressing Enter submits the entry.
// my-plugin/src/js/SharePanel.js
Craft.MyPluginSharePanel = Garnish.Base.extend({
$email: null,
$requirePassword: null,
$submit: null,
init: function (elementId, settings) {
this.setSettings(settings, Craft.MyPluginSharePanel.defaults);
this.elementId = elementId;
// Read by id — these inputs deliberately have no `name`, so they are
// invisible to the surrounding element form's serialization.
this.$email = $('#my-plugin-share-email');
this.$requirePassword = $('#my-plugin-share-requirePassword');
this.$submit = $('#my-plugin-share-submit');
this.addListener(this.$submit, 'activate', 'createLink');
// Enter inside our fields must not submit the ENTRY form. Craft's form
// handles submit on the <form> element, so stop the keydown here before
// it bubbles — and do the panel's own action instead.
this.addListener(this.$email, 'keydown', function (ev) {
if (ev.keyCode === Garnish.RETURN_KEY) {
ev.preventDefault();
ev.stopPropagation();
this.createLink();
}
});
},
createLink: function () {
// Lightswitch state comes from the Garnish widget, not a checked property.
var data = {
elementId: this.elementId,
email: this.$email.val(),
requirePassword: this.$requirePassword.data('lightswitch').on ? 1 : 0,
};
// Our own endpoint — Craft.sendActionRequest adds the CSRF token.
Craft.sendActionRequest('POST', 'my-plugin/share/create', {data: data})
.then(
function (response) {
Craft.cp.displayNotice(Craft.t('my-plugin', 'Share link created.'));
}.bind(this),
)
.catch(function (e) {
Craft.cp.displayError(e?.response?.data?.message);
});
},
});
Craft.MyPluginSharePanel.defaults = {};Details worth knowing:
preventDefault()alone may not be enough — usestopPropagation()too so the keydown never reaches the form's handler.Garnish.RETURN_KEYover the magic number13.- Read lightswitches through the widget (
$el.data('lightswitch').on), not.is(':checked')— Craft's lightswitch is not a checkbox. activate, notclick, on the submit button, so keyboard users get the same behavior (this is why the button istype="button").Craft.sendActionRequesthandles the CSRF token; hand-rolled$.postto an action URL will 400 without it.- Call
destroy()if the panel can be torn down and rebuilt — slideouts and live preview recreate their DOM, and listeners added withaddListener()are whatdestroy()cleans up. Seeclass-system.md.
Custom Field Input JS & Dynamic Re-init
The hard part of CP field types isn't the Garnish class — it's getting it to run, against the right element, in every context (main edit page, Matrix block, slideout, element editor).
Instantiate from a namespaced {% js %} block. Your field's inputHtml registers JS keyed on the namespaced input id (see the craftcms skill's field-types-custom.md), so the selector matches the DOM after Craft namespaces it:
{# my-plugin/fields/_input.twig — `id` is already namespaced by inputHtml #}
<div id="{{ id }}-wrapper" data-value="{{ value }}"></div>
{% js %}
new Craft.MyPlugin.MyInput('#{{ id }}-wrapper');
{% endjs %}A hardcoded selector (#myInput) collides, because the same inputHtml renders at many namespaces. The class itself is a Garnish.Base.extend class (see class-system.md).
Re-init on dynamically loaded inputs. When Craft loads a field into a Matrix block, slideout, or element editor, the server returns headHtml + bodyHtml and Craft runs, in order:
await Craft.appendHeadHtml(data.headHtml); // <link>/<script src>, deduped
await Craft.appendBodyHtml(data.bodyHtml); // executes inline {% js %} → your `new Craft.MyInput(...)` runs here
Craft.initUiElements($container); // wires Garnish/Craft widgets in the new markupSo your {% js %} re-runs automatically because it's part of bodyHtml — you wire nothing up. But if you build markup in JS (not from a server bodyHtml), you must call Craft.initUiElements($yourMarkup) yourself, scoped to the new container. (appendHeadHtml/appendBodyHtml return Promises — await them before initUiElements.)
Destroy discipline. Matrix blocks get removed and slideouts torn down repeatedly. Listeners scoped to your own $container are GC'd when its DOM is removed — but Garnish.on(...) / addListener bound to Garnish.$bod / $win (document/window) survive and leak. Keep listeners on your container, and override destroy() (calling this.base()) for widgets that bind document/window-level events.
For the higher-level Craft.* classes these build on (Craft.ElementEditor, Craft.CpScreenSlideout, element-select inputs), see the craftcms skill's cp-components.md.
Element Index JS Loading
Custom element index classes (extending Craft.BaseElementIndex) and element editor classes require regular script loading through Yii2 asset bundles. Do NOT load them via nystudio107/craft-plugin-vite — the type="module" attribute causes deferred execution that breaks the synchronous lookup in Craft.createElementIndex().
This pattern comes from Commerce's ProductIndexAsset. Three files involved:
1. Asset bundle — reads the Vite manifest for the hashed filename
use craft\web\AssetBundle;
use craft\web\assets\cp\CpAsset;
use craft\helpers\Json;
class MyElementIndexAsset extends AssetBundle
{
public function init(): void
{
$this->sourcePath = '@vendor/myplugin/web/assets/dist/';
$this->depends = [
CpAsset::class,
MyPluginCpAsset::class, // provides Craft.MyPlugin namespace + data
];
// Read manifest to resolve hashed filename
$manifestPath = Craft::getAlias($this->sourcePath . 'manifest.json');
if (is_file($manifestPath)) {
$manifest = Json::decodeIfJson(file_get_contents($manifestPath));
if (isset($manifest['src/js/MyElementIndex.js']['file'])) {
$this->js = [$manifest['src/js/MyElementIndex.js']['file']];
}
}
parent::init();
}
}2. Controller — registers the asset bundle before rendering
public function actionIndex(): Response
{
$this->getView()->registerAssetBundle(MyElementIndexAsset::class);
return $this->renderTemplate('my-plugin/elements/_index', [...]);
}3. Template — clean, no JS loading
{% extends '_layouts/elementindex' %}
{% set title = 'My Elements'|t('my-plugin') %}
{% set elementType = 'vendor\\plugin\\elements\\MyElement' %}
{% if typeHandle is defined %}
{% js %}
window.defaultTypeHandle = '{{ typeHandle }}';
{% endjs %}
{% endif %}Data injection pattern
The CP asset bundle (e.g., MyPluginCpAsset) injects editable types at POS_HEAD as inline JS:
$js = 'window.Craft.MyPlugin = {};' . PHP_EOL;
$js .= 'window.Craft.MyPlugin.editableTypes = ' . Json::encode($types) . ';';
$view->registerJs($js, View::POS_HEAD);The element index JS reads from this in afterInit() — a lifecycle hook on Craft.BaseElementIndex (not Garnish.Base) that fires at the end of BaseElementIndex.init(), after sources and the UI are set up. Subclasses use it to avoid overriding the full init chain:
Craft.MyPlugin.MyElementIndex = Craft.BaseElementIndex.extend({
editableTypes: null,
// Called by BaseElementIndex at the end of init() — sources are available
afterInit: function() {
this.editableTypes = [];
for (const type of Craft.MyPlugin.editableTypes) {
if (this.getSourceByKey('type:' + type.uid)) {
this.editableTypes.push(type);
}
}
this.base();
},
});
Craft.registerElementIndexClass(
'vendor\\plugin\\elements\\MyElement',
Craft.MyPlugin.MyElementIndex
);Execution order (regular script pattern)
1. POS_HEAD inline: window.Craft = {...} (CpAsset data)
2. POS_HEAD inline: window.Craft.MyPlugin.editableTypes = [] (plugin data)
3. POS_END script: cp.js (BaseElementIndex, registerElementIndexClass)
4. POS_END script: my-element-index.js (defines class, calls registerElementIndexClass)
5. POS_READY: Craft.createElementIndex() (finds registered class)When Vite register() IS fine
Vite module loading works for JS that doesn't need to execute before Craft.createElementIndex():
- Field type JS
- Settings page JS
- General CP enhancements
- Anything that runs on DOMContentLoaded or later
It's specifically element index classes (and likely element editor classes) that need the regular-script asset bundle pattern because Craft's factory methods look them up synchronously.
Form Widgets
NiceText
Auto-growing textarea with optional character count and hint text.
new Garnish.NiceText($textarea, {
autoHeight: true, // Auto-grow textarea height (default: true)
showCharsLeft: false, // Show remaining character count
charsLeftClass: 'chars-left',
onHeightChange: $.noop, // Callback when height changes
});Features:
- Auto-height: Calculates height based on content using a hidden
<stage>element - Character count: If
maxlengthattribute exists andshowCharsLeftis true, displays remaining chars witharia-live="polite" - Hint text: If
settings.hintis set, shows placeholder-like overlay that fades on input - Ctrl+Enter: Submits the closest
<form> - Uses
textchangecustom event for reliable value tracking
CheckboxSelect
Checkbox group with "Select All" toggle.
new Garnish.CheckboxSelect($container);Features:
- Finds checkbox with
.allclass as the "select all" toggle - When "all" is checked, individual checkboxes are disabled
- When "all" is unchecked, individual checkboxes re-enable
- Optional
localStoragepersistence viastorageKeysetting (usesCraft.getLocalStorage()/Craft.setLocalStorage()) - Listens for
changeevents on checkboxes
MultiFunctionBtn
Button with multiple states: idle, busy, failure, retry, success.
var btn = new Garnish.MultiFunctionBtn($button, {
busyClass: 'loading', // Class during busy state
changeButtonText: false, // Update button label text
clearLiveRegionTimeout: 2500, // Clear SR announcement after ms
failureMessageDuration: 3000, // Show failure message for ms
});
// State transitions
btn.busyEvent(); // Show loading state
btn.successEvent(); // Show success message
btn.failureEvent(); // Show failure, then retry messageData attributes on the button:
data-busy-message— Text during busy state (default: "Loading")data-success-message— Text on success (default: "Success")data-failure-message— Text on failuredata-retry-message— Text after failure clears
Uses role="status" live region for screen reader announcements.
MixedInput
Text input that supports inline tag elements mixed with text. Used in Craft's condition builder, search inputs, and anywhere users type text interspersed with tag-like chips. This is a low-level internal class — most code interacts with the higher-level widgets built on top of it rather than instantiating it directly.
var input = new Garnish.MixedInput($container);
// Add a removable tag/chip element inline at the caret
var $tag = $('<span class="token">Category</span>');
input.addElement($tag);
// Add a new text segment at the current caret position
input.addTextElement();Manages caret positioning, keyboard navigation between text and elements, and element insertion/removal within the mixed content. (Each text segment is a private nested TextElement instance — getVal() lives on that inner class, not on MixedInput.)
Key Methods:
getElementIndex($elem)— Get the index of an element within the inputisText($elem)— Whether the given element is a text segmentaddTextElement(index, focus)— Add a new text segment at the current caret positionaddElement($element, index, focus)— Add a tag/chip element inline at caretremoveElement($element)— Remove an inline element and merge adjacent text nodessetFocus($elem)— Focus a specific elementblurFocussedElement()— Blur the currently focused elementfocusPreviousElement($from)/focusNextElement($from)— Move focus to the adjacent elementfocusStart()/focusEnd()— Move focus to the first/last elementsetCaretPos($elem, pos)— Set the caret position within a text element
See Also
class-system.md— Garnish.Base.extend() pattern used by all Craft.* classes, event system, addListenerui-widgets.md— Full API for Modal, HUD, DisclosureMenu, Select — the widgets used in Twig JS blocksdrag-system.md— DragSort and DragDrop APIs for reorderable and droppable interactionsutilities.md— Key constants, custom events (activate, textchange, resize), ARIA helpers used in CP templates