JavaScript
The browser-side API: the per-element interfaces Forms and Popups attach, the events they fire, and the editor filters they run through @wordpress/hooks.
XPAC exposes two different JavaScript surfaces, and it is worth knowing which one you need before you write anything.
| Surface | Where it runs | What it is |
|---|---|---|
Filters through @wordpress/hooks | Mostly the block editor | addFilter() on a named hook, the same API WordPress core uses |
| Element interfaces | The front end | An object each plugin attaches to the DOM element it controls |
The first is listed in full in the hook reference —
every JavaScript hook is generated from its applyFilters() call site, with its
arguments. This page covers the second, which is not generated because it is not
a hook: it is an object on an element, and it is the only way to drive a form or
a popup from outside.
Getting your script on the page
Enqueue against the handle you want to run after. The handles are stable:
| Handle | What it is |
|---|---|
xp-form-view-script | The Forms front-end bundle. Attaches form.xpacForm |
xp-form-editor-script | The Form block's editor script |
xpac-form-settings | The form editor sidebar, where the Forms editor filters run |
xpac-popup | The Popups front-end bundle. Attaches template.xpacPopup |
add_action('wp_enqueue_scripts', function () {
wp_enqueue_script(
'my-form-extras',
plugins_url('my-form-extras.js', __FILE__),
['xp-form-view-script'],
'1.0.0',
true
);
});The Forms bundle is enqueued by the block, so it is only on pages that render
a form. A dependency on xp-form-view-script means your script is only on
those pages too, which is usually what you want — but it also means a page
with no form never loads it.
Forms
The lifecycle
A form is initialised once. The interface is attached first, then two events announce it, in this order:
| Event | Fired on | Detail | When |
|---|---|---|---|
initialize | the <form> element | — | form.xpacForm has just been attached |
xpacFormInitialize | document | { form } | Immediately after, for listeners that were not bound to the element |
xpacFormsReady | document | — | Once per page, after every form present at load has been initialised |
Listen on document unless you already hold the element. A form inserted later —
by a popup, a router, an AJAX load — fires xpacFormInitialize too, and never
fires xpacFormsReady a second time.
document.addEventListener('xpacFormInitialize', (event) => {
const form = event.detail.form;
form.xpacForm.callbacks.registerSuccessCallback(() => {
console.log('submitted', form.xpacForm.utils.getId());
});
});To initialise a form the bundle has not seen — one you built and inserted
yourself — call window.initializeXpacForm(formElement). It is a no-op on an
element that is not .xp-block-form or that already has an interface.
form.xpacForm
Five namespaces. Everything a submission does can be observed or changed through them.
| Method | Runs |
|---|---|
registerSubmitCallback(cb) | After validation passes, before the request goes out |
registerSuccessCallback(cb) | The server accepted the submission |
registerErrorCallback(cb) | The request failed or the server rejected it |
registerFinallyCallback(cb) | Once per submission, whatever happened |
Submit callbacks are awaited, and the form validates a second time once they have all resolved — so a callback may add or change values and still be caught by the rules.
registerFinallyCallback receives { resolve, reject, signal }. Every
registered callback is awaited before the form leaves its busy state, so a
callback that never settles would hang the form — one that has not settled
by the configured callback timeout is released.
| Method | What it does |
|---|---|
setRules(fields, config) | Add or replace rules for named fields |
validateField(name, value) | Run the current rules for one field |
getDisabledRules() / setDisabledRules(rulesOrCallback) | Read or replace the list of rules the form skips |
registerErrorRenderCallback(name, cb) | Take over how one field's error is drawn |
registerErrorRemoveCallback(name, cb) | Take over how it is cleared |
getMessageMarkup | The markup helper the built-in renderer uses |
setDisabledRules accepts either an array or a callback that receives the
current array and returns the replacement.
| Method | What it does |
|---|---|
render(status, message, persistent = false) | Draw a success or error message |
remove() | Clear it |
Use this rather than writing your own element. It puts the message through the form's own markup allowlist, live-region role, focus handling and configured position — which matters most for add-ons that come back to the page with an outcome, such as a payment or an OAuth redirect, where no submission round trip produced the message.
| Method | What it does |
|---|---|
getByName(name) | The controls carrying that name attribute |
setDisabled(field, owner, disabled) | Disable or enable a field on behalf of a named owner |
triggerChange(field) | Fire the change event the form listens for |
getChangeEvents(field) | Which events that control type reports on |
registerMask(field, mask, options) | Mask a field as the visitor types. Returns nothing |
setDisabled counts owners rather than setting a flag. A field stays
disabled while any owner still holds it, so two add-ons disabling the same
field cannot re-enable each other's. Pass any stable string as owner.
registerMask is always present. A mask string made of 0 digit slots
and literals (any character IMask does not read as a pattern, such as
(, ), -, + or a space), with only lazy and prepare in options, runs
on the built-in masker, which needs no library and also masks a form
rendered after the page's scripts. Any other IMask option, such as
blocks or definitions, runs on window.IMask. When the page has not
loaded it, registerMask loads the xpac-form-mask script first and
masks the field once it arrives. Code that uses window.IMask directly
enqueues xpac-form-mask itself, since the phone field no longer loads it.
| Method | What it does |
|---|---|
getId() | The form's post ID |
getInstanceId() / restoreInstanceId(id) | The per-render instance ID, and putting a saved one back |
getFormData() | The current values as FormData |
setBusyState(state) | Lock or unlock the form |
cancelSubmission() | Abort the request in flight |
getFieldBlockClassName(block), getFieldBlockGenericClassName(), getInvalidClassName() | The class names the stylesheet uses |
snapshot.create(key, data), .read(key), .remove(key), .submit(key, extra, removeAfter) | Save the filled-in form, restore it, submit it later |
Snapshots are what let a visitor leave the page — to a payment provider, to an identity provider — and have the submission complete when they return.
config
config.setOption(name, value) and config.getOption(name, fallback) hold
per-form settings. setOption will not overwrite a name that is already set, so
the first writer wins.
window.xpacForms
Read-only data printed once per page, before the form bundle, the first time a form renders. It is deep-frozen, so nothing can change it in the browser.
| Key | Contents |
|---|---|
nonce | A wp_rest nonce for the submission request |
healthCheckUrl | The endpoint the bundle calls to refresh an expired nonce |
validationMessages | The site's validation message strings, including phoneNumber and phoneNumberCountry for Smart international phone fields |
submissionMessages | The site's submission message strings |
maskScript | The address of the IMask library (xpac-form-mask), loaded by registerMask only when a mask needs it |
registeredBlocksConfig | The field blocks registered on the site |
phoneField | Phone field data: masks (phone formats added through xpac_forms_phone_masks), siteCountry (the site's two-letter country, or '') and i18n (the country picker's labels). Present only on a page that renders a Smart international field or a field with an add-on phone format |
Add-ons put their own front-end data here through the
xpac_forms_inline_js_data
filter — Turnstile and reCAPTCHA their site keys, Stripe its publishable key. The
keys above are merged in afterwards and cannot be replaced.
Popups
template.xpacPopup
Every popup template element carries an interface. It is attached before the
element's initialize event fires, which is the point to register a checker.
| Method | What it does |
|---|---|
getSettings() | The popup's saved settings, including its rules array |
getState() | { open, activeRule, closedByClick } |
setState(partial) | Merge into that state |
registerOpenChecker(cb) | Veto an open. Return false to stop it |
registerHideChecker(cb) | Veto a close the same way |
Both checkers receive (rule, template). Every registered open checker has to
return truthy for the popup to open — one false is enough to hold it back.
document.addEventListener('initialize', (event) => {
const template = event.target;
if (!template.xpacPopup) {
return;
}
template.xpacPopup.registerOpenChecker(
() => !document.body.classList.contains('checkout-in-progress')
);
}, true);The listener is capturing because initialize is dispatched on the template
element and does not bubble.
Events
Four events, each fired twice: once on the template element and once on
document.
| Event | When | Detail |
|---|---|---|
xpac-popup-pre-open | Before the popup is built and shown | { rule } |
xpac-popup-post-open | After it is shown and focused | { rule, requested } |
xpac-popup-pre-close | Before it is hidden | { rule } |
xpac-popup-post-close | After it is hidden | { rule } |
The document copy adds template to the detail, since a document listener has
no other way to tell which popup it is hearing about. requested is true when
the open came from an explicit request — a Popup Selector block or a trigger —
rather than from a display rule matching.
Listen on document for anything site-wide, and on the element when you already
have it. Do not listen for both, or you will handle every popup twice.
Editor filters
Both plugins run their editor UI through @wordpress/hooks, so an add-on adds a
panel or changes a field without touching the plugin.
| Filter | What it changes |
|---|---|
xpac-forms-form-panels | The panels in the form's document sidebar |
xpac-forms-field-panels | The panels shown when a field block is selected |
xpac-forms-field-output-props | The props a field renders with |
xpac-forms-field-name | The generated name attribute of a new field |
xpac-forms-after-settings-modal-content | Content appended inside a settings modal |
xpac-popups-popup-panels | The panels in the popup sidebar |
xpac-popups-types / xpac-popups-type-labels | The popup types and their labels |
xpac-popup-display-rule-additional-components | Extra controls in the display-rule editor |
xpac-popups-display-rule-default-config | The defaults a new display rule starts from |
Five more run on the front end rather than in the editor:
xpac-popups-triggers, xpac-popups-animation, xpac-popups-is-modal,
xpac-popups-is-conversion and xpac-popups-conversion-selector.
Each one's arguments, defaults and call site are in the hook reference.
import { addFilter } from '@wordpress/hooks';
addFilter('xpac-forms-form-panels', 'my-plugin/panels', (panels) => [
...panels,
{ name: 'my-panel', title: 'My panel', Component: MyPanel },
]);Package anatomy
Inside a package: Bootstrap.php, namespaces, components, and how blocks, settings and REST routes get registered.
Styling
How Forms and Popups are styled: the theme.json tokens Forms reads, the custom properties Popups exposes, the class names both emit, and how to switch the shipped CSS off.