Skip to content

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:

import {vuePlugin} from '@AmarantUi/js/i18n';
app.use(vuePlugin);

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, errordanger, 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 the name attribute 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 in name/id/data-validated-element/for attributes, 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:

validateValue(value, [{validator: 'range', params: {from: 1, to: 100}}]);

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:

export default {
    computed: {
        results() {
            return this.$getStore('searchStore').results;
        }
    }
}

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:

<template>
    <AmaComponent :is="dynamicComponentName" v-bind="props" />
</template>

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:

<input x-model="$store.formStore.forms.my_form.my_field"
       x-on:change="$store.formStore.onFieldChange('my_form', 'my_field', $el)">