Javascript utilities
The Amarant_Ui and Amarant_Framework modules expose shared JavaScript utilities available
in every area. Import them using their module alias — no path traversal needed.
Event system
Import: @AmarantFramework/lib/events
A lightweight pub/sub bus used throughout the framework. Subscribers are called in descending
priority order. All subscriber functions are called with await, so async subscribers are
fully supported.
import {subscribe, unsubscribe, resubscribe, dispatch} from '@AmarantFramework/lib/events';
// Subscribe — higher priority runs first
subscribe('my.event', async (data) => {
console.log(data);
}, 10);
// Dispatch — awaits every subscriber in priority order
await dispatch('my.event', {key: 'value'});
// Remove a specific subscriber from all events
unsubscribe(myHandler);
// Remove and re-add with a new priority
resubscribe('my.event', myHandler, 20);
| Export | Signature | Description |
|---|---|---|
subscribe |
(eventName, fn, priority = 0) |
Register a subscriber. Same function reference registered twice is silently ignored. |
unsubscribe |
(fn) |
Remove a subscriber from every event it is registered on. |
resubscribe |
(eventName, fn, priority = 0) |
unsubscribe + subscribe in one call. |
dispatch |
async (eventName, data) |
Call all subscribers in descending priority order. Errors are caught and logged; the dispatch continues. |
Application state
Import: @AmarantFramework/application.state
Reads the server-injected state from window.amarant_application. The object is set by the
PHP renderer and contains channel info, i18n translations, and other data injected at render time.
import {getApplicationState} from '@AmarantFramework/application.state';
const state = getApplicationState();
const locale = state['amarant_channel']?.['locale'];
const translations = state['amarant']?.['i18n']?.[locale];
The result is memoised after the first call. Returns {} when called outside a browser
context (e.g. during SSR or testing).
Logger
Import: @AmarantFramework/logging/logger
Thin console wrapper that is silenced in production builds (import.meta.env.PROD).
import {log} from '@AmarantFramework/logging/logger';
log.info('Something happened');
log.warn('Unexpected state');
log.error('Something failed');
| Export | Description |
|---|---|
log.info(message) |
console.log in development, no-op in production. |
log.warn(message) |
console.warn in development, no-op in production. |
log.error(message) |
console.error in development, no-op in production. |
API error helpers
Import: @AmarantFramework/util/api.details
Utilities for extracting human-readable messages from the standard API error response shape
({ message, details: [{text, messageId, errors}] }).
import {
apiDetailsToMessages,
fromErrorResponse,
fromErrorResponseOrDefault,
isForbidden,
isForbiddenResource,
apiDetailsToPathsObject
} from '@AmarantFramework/util/api.details';
// Extract messages from a parsed response body
const messages = apiDetailsToMessages(responseData); // string[]
// Extract from a raw jQuery/axios error object
const messages = fromErrorResponse(errorResponse); // string[]
const message = fromErrorResponseOrDefault(errorResponse, 'Something went wrong.'); // string
// Check for well-known error IDs
if (isForbidden(responseData)) window.location.reload(true); // SEC-001
if (isForbiddenResource(responseData)) { /* handle 403 */ } // API-008
// Map validation errors to { fieldPath: errorText } for form field binding
const fieldErrors = apiDetailsToPathsObject(responseData);
| Export | Description |
|---|---|
apiDetailsToMessages(data) |
Extract all detail.text strings from the response. Falls back to data.message. |
apiDetailsToMessageOrDefault(data, default) |
First message or translated default. |
apiDetailsToErrorIds(data) |
Extract all detail.messageId strings. |
apiDetailsToPathsObject(data) |
Return { path: errorText } from detail.errors[].path. |
fromErrorResponse(err) |
Handle jQuery, axios, or raw response shapes — extracts messages. |
fromErrorResponseOrDefault(err, default) |
First message from any response shape, or translated default. |
isForbidden(data) |
true when messageId === 'SEC-001' (auth required). |
isForbiddenResource(data) |
true when messageId === 'API-008' (insufficient permissions). |
Translations (i18n)
Import: @AmarantUi/js/i18n
Wraps i18next. Translations are loaded from the server-injected application state and keyed
by locale.
import {__, vuePlugin, locale} from '@AmarantUi/js/i18n';
// Translate a string
const label = __('Save changes');
// With interpolation (uses {{ }} delimiters in Vue context)
const msg = __('Hello, {{name}}', {name: 'World'});
// Current locale string (e.g. 'en', 'de')
console.log(locale);
window.__ is set to __ so legacy inline scripts can call it too.
vuePlugin is a Vue plugin that adds this.$__() to Vue components:
In Vue templates, use $__('key') or $__('Hello {name}', {name: 'World'}) (Vue context
uses { / } delimiters instead of {{ / }}).
Flash messages
Import: @AmarantUi/js/messages
Stores messages in localStorage and renders them into a #page-messages element (or any
element ID you specify). Survives page redirects — messages written before a redirect are
displayed after the next page loads.
import {addMessage, replaceMessages, renderMessages, clearMessages, getCookieMessages} from '@AmarantUi/js/messages';
// Add a message to localStorage (does not render immediately)
addMessage({type: 'success', content: 'Saved!'});
// Overwrite all messages and render immediately
replaceMessages([{type: 'error', content: 'Something went wrong.'}]);
// Render all stored messages into the DOM (clears localStorage after rendering)
renderMessages();
// Render into a custom element
renderMessages('my-message-container');
// Clear without re-rendering
clearMessages(false);
// Read and clear cookie-set messages (set server-side via `ama_messages` cookie)
const cookieMessages = getCookieMessages();
Message types map to Bootstrap alert variants: success, error → danger, warning,
info.
Notifier (dialogs and toasts)
Import: @AmarantUi/js/notifier
Thin wrappers around SweetAlert2.
import {confirmation, toast} from '@AmarantUi/js/notifier';
// Modal confirmation dialog
const result = await confirmation(
'Are you sure you want to delete this item?', // html body
'Confirmation', // title
'warning', // icon: success|error|warning|info|question
'Yes, delete', // confirm button text
'Cancel' // deny button text (omit for no deny button)
);
if (result.isConfirmed) { /* proceed */ }
// Toast notification (auto-dismisses)
await toast('Changes saved', 'success', 3000, 'top-end');
| Export | Signature | Description |
|---|---|---|
confirmation(text, title, icon, confirmText, denyText?) |
async → SwalResult |
Show a modal confirmation dialog. |
toast(title, icon?, timer?, position?) |
async → void |
Animated toast with progress bar. Default: success, 5 s, top-end. |
toastDialog(title, icon?, timer?, position?) |
void |
Non-async version of toast without progress bar. |
initToast() registers window.$toast = toast for use in Alpine/inline handlers.
Form handler
Import: @AmarantUi/js/form/form
The universal form submission handler used by all backend (and most frontend) forms. It handles validation, CKEditor data extraction, AJAX submission, error display, redirect on success, and a full event pipeline.
Wiring a form
A submit button triggers handleForm when it has:
class="submit-form"(or any selector you pass)data-form="<form_name>"matching thenameattribute on the<form>element
<button type="button" class="btn btn-success submit-form" data-form="my_form_name">
Save
</button>
<form name="my_form_name" action="{{ route('backend.something.save') }}" method="post">
...
</form>
import {handleForm} from '@AmarantUi/js/form/form';
// Called once after DOM ready (the Ui module does this automatically for '.submit-form')
handleForm('.submit-form');
// Custom selector or message container element ID
handleForm('[data-role="my-submit"]', 'my-message-el');
Submission flow
click submit button
↓ checkValidity() — HTML5 + data-validations attributes
↓ dispatch EVENT_FORM_HANDLER_PREPARE → stop() to abort
↓ dispatch EVENT_FORM_HANDLER_SUBMIT → stop() to abort
↓ dispatch EVENT_FORM_HANDLER_BEFORE_SUBMIT
↓ $.ajax POST with FormData
success → dispatch EVENT_FORM_HANDLER_SUBMIT_SUCCESS
→ redirect if response.url, else show response.message
error → dispatch EVENT_FORM_HANDLER_SUBMIT_ERROR
→ map detail errors to form field error elements
→ or show global error messages
↓ dispatch EVENT_FORM_HANDLER_SUBMIT_COMPLETE
↓ re-enable buttons
Events
All events are dispatched via the event system. Subscribe with
subscribe(eventName, handler).
| Constant | Event name | data properties |
|---|---|---|
EVENT_FORM_HANDLER_START |
'form_handler_start' |
name, element |
EVENT_FORM_HANDLER_PREPARE |
'form_handler_prepare' |
name, element, stop() |
EVENT_FORM_HANDLER_SUBMIT |
'form_handler_submit' |
name, element, data (FormData), url, stop() |
EVENT_FORM_HANDLER_BEFORE_SUBMIT |
'form_handler_before_submit' |
same as SUBMIT |
EVENT_FORM_HANDLER_SUBMIT_SUCCESS |
'form_handler_submit_success' |
+ responseData; set handled = true to suppress default behaviour |
EVENT_FORM_HANDLER_SUBMIT_ERROR |
'form_handler_submit_error' |
+ responseData; set handled = true to suppress default behaviour |
EVENT_FORM_HANDLER_SUBMIT_COMPLETE |
'form_handler_submit_complete' |
same as SUBMIT |
EVENT_FORM_HANDLER_COLLECTION_TABLE_ADD |
'form_handler_collection_table_add' |
name, element, item (new row jQuery object) |
Calling event.stop() on PREPARE or SUBMIT cancels the submission and re-enables buttons.
Setting event.handled = true on SUCCESS or ERROR suppresses the built-in response handling
(redirect / error messages).
Form event object
Created by createFormEvent from @AmarantUi/js/form/form.event:
import {createFormEvent} from '@AmarantUi/js/form/form.event';
const event = createFormEvent({name: 'my_form', element: $formEl});
event.stop(); // sets event.stopped = true
event.stopped; // checked by the form handler to abort
Form collection tables
Forms with repeatable rows use:
.form-collection-table-add— button that appends a template row (data-table-id,data-template-id).form-collection-table-remove— button inside each row that removes it%index%— placeholder inname/id/data-validated-element/forattributes, replaced with the row index automatically
Field validation
HTML5 required and custom data-validations="ruleName anotherRule" attributes on inputs
are evaluated on submit. Error messages are rendered into [data-validated-element="<name>"]
elements. See Validator for available rules.
Validator
Import: @AmarantUi/js/validator
import {validateValue, validateObject, validateObjects, setValidator} from '@AmarantUi/js/validator';
// Validate a single value against named rules
const errors = validateValue('', ['required', 'greaterThanZero']);
// → ['This field is required.', 'Must be a number greater than 0.']
// → null if all pass
// Validate an object's properties
const ok = validateObject(
{price: 0, name: ''},
{price: ['greaterThanZero'], name: ['required']},
(field, message) => console.error(field, message)
);
// → false
// Validate all items in a collection
validateObjects(items, {qty: ['greaterThanZero']}, (index, field, message, item) => {});
// Register a custom rule
setValidator('positiveInt', (value) => {
if (!Number.isInteger(Number(value)) || Number(value) <= 0) {
return __('Must be a positive integer.');
}
});
Built-in rules
| Rule | Description |
|---|---|
required |
Fails on empty string, null, undefined, or false. |
greaterThanZero |
Fails if value is not a number or ≤ 0. |
greaterThanZeroOrEmpty |
Passes when empty; fails if non-empty and not a number > 0. |
range |
Fails if not a number between params.from and params.to. Passes when empty. |
Pass params for range as an object in the validations array:
Input initializer
Import: @AmarantUi/js/input/input.initializer
Initialises enhanced form controls within a given parent element. Call after dynamically inserting new form fields into the DOM.
import {initSelectElements, initDateTimeElements, initEditorElements} from '@AmarantUi/js/input/input.initializer';
// Re-initialise all enhanced inputs inside a newly added panel
initSelectElements('#my-panel');
initDateTimeElements('#my-panel');
initEditorElements('#my-panel');
| Export | What it does |
|---|---|
initSelectElements(parentEl?, callback?) |
Wraps <select multiple> and <select class="form-choices-select"> elements with Choices.js. Adds tag creation for <select class="taggable">. |
initDateTimeElements(parentEl?, callback?) |
Binds input.input-date-time and input.input-date elements with TempusDominus date/time pickers. |
initEditorElements(parentEl?, callback?) |
Binds textarea.ckeditor elements with a CKEditor 5 instance. |
The optional callback(originalElement, newInstance) is called for each element after it is
initialised, allowing additional configuration.
Confirm-redirect handler
Import: @AmarantUi/js/confirm.redirect.handler
Activates a confirmation dialog on any element with data-confirm-href. On confirmation,
redirects to data-href.
<button data-confirm-href
data-href="{{ route('backend.product.delete', {id: product.id}) }}"
data-confirm-href-title="{{ 'Delete product'|trans }}">
Delete
</button>
import {confirmRedirectHandler} from '@AmarantUi/js/confirm.redirect.handler';
// Called once after DOM ready — the Ui backend module does this automatically
confirmRedirectHandler();
Vue and Alpine integration
The framework ships two distinct reactive systems: Alpine.js for lightweight in-template
reactivity (data grids, small widgets) and Vue 3 for full component trees. Each has its
own store and registration mechanism. Both are initialised at the end of the generated
bootstrap, after all module.js files have run.
Alpine stores
Import: @AmarantFramework/app/store/registrar
Alpine stores are registered via addStore. This registrar is completely separate from the
Vue store system — different import path, different runtime, different API. A store added
here is a plain reactive object, not a Pinia store.
// in module.js
import {addStore} from '@AmarantFramework/app/store/registrar'; // Alpine registrar
import {registerStore} from '@AmarantFramework/ui/vue/app'; // Vue/Pinia registrar — different
const channelScopeStore = {
scopeCode: null,
setScopeCode(code) { this.scopeCode = code; }
};
addStore('channelScopeStore', channelScopeStore); // → Alpine $store.channelScopeStore
At the end of the bootstrap sequence, alpineInitialize() reads every store from the
registrar, calls Alpine.store(name, storeObject) for each one, and then calls
Alpine.start(). You must register all stores from module.js — after Alpine has started,
Alpine.store() can still add stores but the timing is less predictable.
<!-- in any Twig template -->
<div x-data>
<span x-text="$store.channelScopeStore.scopeCode"></span>
<button x-on:click="$store.channelScopeStore.setScopeCode('us')">Switch</button>
</div>
The same module.js often registers both Alpine and Vue stores for different purposes:
// code/Amarant/Channel/Resources/frontend/web/module.js
import {registerStore} from '@AmarantFramework/ui/vue/app'; // Vue/Pinia
import {addStore} from '@AmarantFramework/app/store/registrar'; // Alpine
import {useSearchStore} from '@AmarantChannel/store/search.store';
import {channelScopeStore} from '@AmarantChannel/store/channel.scope.store';
registerStore('searchStore', useSearchStore); // Pinia factory → Vue $getStore()
addStore('channelScopeStore', channelScopeStore); // plain object → Alpine $store.*
Do not call Alpine.start() yourself — it is called once by the framework bootstrap.
Vue stores (registerStore)
Import: @AmarantFramework/ui/vue/app
Vue stores are Pinia store factories (functions that return a store when called). Register
them with registerStore so the framework calls the factory and attaches the store instance
to every Vue app that is mounted on the page.
// in module.js
import {registerStore} from '@AmarantFramework/ui/vue/app';
import {useSearchStore} from '@AmarantChannel/store/search.store';
registerStore('searchStore', useSearchStore);
Inside Vue components, retrieve the store via the $getStore(name) global property (added by
storesPlugin):
<script setup>
import {getCurrentInstance} from 'vue';
const {proxy} = getCurrentInstance();
const searchStore = proxy.$getStore('searchStore');
</script>
Or with the Options API:
Alpine vs Vue stores
addStore (Alpine) and registerStore (Vue) are separate registrars. An Alpine store
is a plain reactive object. A Vue store is a Pinia factory function. Do not mix them.
Vue components (registerComponent)
Import: @AmarantFramework/ui/vue/app
Register a Vue SFC or component options object under a string name. The framework scans the DOM after bootstrap for mounting instructions and looks up components by that name.
// in module.js
import {registerComponent} from '@AmarantFramework/ui/vue/app';
import SearchComponent from '@AmarantChannel/component/search';
registerComponent('app.search', SearchComponent);
Mounting Vue components from Twig
Once a component is registered, mount it on any DOM element by emitting a
<script type="text/x-amarant-component"> tag from a Twig template. The value is a JSON
object mapping CSS selectors to mount configurations:
<div id="search-widget"></div>
<script type="text/x-amarant-component">
{
"#search-widget": {
"component": "app.search",
"data": {
"placeholder": "{{ 'Search...'|trans }}"
},
"storeData": {
"searchStore": {
"initialQuery": "{{ app.request.query.get('q')|e('js') }}"
}
}
}
}
</script>
| Field | Type | Description |
|---|---|---|
component |
string |
Name passed to registerComponent. |
data |
object |
Props passed to the root component. |
storeData |
object |
{storeName: {key: value}} — merged into the named store before mounting. Useful for server-injected initial state. |
Each matched DOM element gets its own Vue app instance mounted on it, with all registered plugins (i18n, PrimeVue, stores, etc.) installed automatically.
Script components (registerScriptComponent)
Import: @AmarantFramework/ui/vue/app
A script component is a plain function (not a Vue SFC) triggered by the same DOM-scanning mechanism. Use it when you need reactive behaviour without a full Vue component.
// in module.js
import {registerScriptComponent} from '@AmarantFramework/ui/vue/app';
registerScriptComponent('my.widget', ({selector, element, data}) => {
// element is the matched DOM node, data comes from "data" in the script tag
element.textContent = data.label ?? 'Hello';
});
Mount it from Twig via <script type="text/x-amarant-script-component"> — the format is
identical to text/x-amarant-component.
Vue plugins (registerPlugin)
Import: @AmarantFramework/ui/vue/app
Register a Vue plugin to be installed on every Vue app instance the framework creates.
import {registerPlugin} from '@AmarantFramework/ui/vue/app';
import MyVuePlugin from './my-vue-plugin';
registerPlugin('my-plugin', MyVuePlugin);
Plugins are resolved from the DI container and installed after the built-in plugins (Pinia,
storesPlugin, componentPlugin, i18n, PrimeVue).
Dynamic component helper (<AmaComponent>)
The componentPlugin registers a global <AmaComponent> (alias <ama-component>) that
renders any registered component by name at runtime:
is is a string name previously passed to registerComponent. If the component is not
found, nothing is rendered.
Alpine stores
formStore
Import: @AmarantUi/js/store/form.store
An Alpine store that tracks form field values for use in reactive templates. Registered as
$store.formStore.
import {formStore} from '@AmarantUi/js/store/form.store';
import {addStore} from '@AmarantFramework/app/store/registrar';
addStore('formStore', formStore);
| Method | Description |
|---|---|
addForm(name, data) |
Register a form's initial field values keyed by field name. |
getFormFieldValue(formName, fieldName) |
Get the current stored value. |
setFormFieldValue(formName, fieldName, value) |
Update a field value (JSON-parses the incoming value). |
onFieldChange(formName, fieldName, element) |
Dispatches 'form.store.on.change' event with form/field context. |
In Alpine templates: