-
Notifications
You must be signed in to change notification settings - Fork 2
PairUI.js
/assets/PairUI.js is Pair's lightweight client-side helper for server-rendered applications.
It keeps HTML central, adds progressive enhancement through data-* directives, exposes a small reactive store, and includes helpers for:
- DOM selection and delegated events
- escaped HTML templates
- async loading states
- HTTP requests
- automatic form autosave
- Pair-style persistent state cookies
- driver-aware toast notifications through
PairUI.toast - opt-in progressive regions, actions and listing filters
Current version:
console.log(PairUI.version); // "0.4.2"PairUI is useful when you want to:
- keep pages server-rendered
- avoid a build step
- use Vanilla JS with a small framework vocabulary
- bind state to DOM through
data-* - reuse lightweight helpers for fetch, loading states and persistent filters
PairUI is not a client-side application framework and is not a replacement for server rendering.
<script src="/assets/PairUI.js"></script>Available globals:
window.PairUI
window.Pair.UIToast configuration is exposed by Pair through window.PairToastConfig.
When the backend calls $app->setToastDriver('sweetalert'), PairUI.toast
will use SweetAlert2 toasts; otherwise it defaults to iziToast.
When the backend calls $app->setToastPosition('topRight'), PairUI.toast
will use that position as the global default.
When no explicit toast position is provided and no global toast position is configured,
PairUI.toast leaves the native default position of the active driver unchanged.
<div id="counter">
Count: <b data-text="count"></b>
<button type="button" data-on="click:inc">+</button>
<button type="button" data-on="click:dec">-</button>
</div>
<script>
PairUI.createApp({
root: document.getElementById('counter'),
state: { count: 0 },
actions: {
inc({ store }) {
store.state.count += 1;
},
dec({ store }) {
store.state.count -= 1;
}
}
});
</script>PairUI.ready(() => {
const form = PairUI.qs('#search-form');
const rows = PairUI.qsa('.table tbody tr');
});Context helper from a nested element:
PairUI.delegate(document, 'click', '.remove-item', (event, button) => {
const ctx = PairUI.ctx(button);
const hiddenId = ctx.qs('input[name="id"]');
console.log(hiddenId ? hiddenId.value : null);
});Direct event binding:
const button = PairUI.qs('[data-role="refresh"]');
function handleRefresh() {
console.log('refresh');
}
PairUI.on(button, 'click', handleRefresh);
// later
PairUI.off(button, 'click', handleRefresh);Delegated click handler:
PairUI.delegate(document, 'click', '[data-remove-row]', (event, trigger) => {
event.preventDefault();
trigger.closest('tr')?.remove();
});Custom event dispatch:
const event = PairUI.emit(document, 'pair:filters-changed', {
page: 1,
sort: 'createdAt'
});
if (event.defaultPrevented) {
console.log('another component intercepted the refresh');
}Debounced input:
const input = PairUI.qs('[data-role="live-search"]');
PairUI.on(input, 'input', PairUI.debounce((event) => {
console.log('search:', event.currentTarget.value);
}, 250));Throttled scroll listener:
PairUI.on(window, 'scroll', PairUI.throttle(() => {
console.log(window.scrollY);
}, 200));PairUI.submit() submits a form with requestSubmit() when available.
Submit a form directly:
const form = PairUI.qs('#filters-form');
PairUI.submit(form);Submit the nearest form from a nested control:
PairUI.delegate(document, 'click', '[data-submit-search]', (event, button) => {
event.preventDefault();
PairUI.submit(button);
});Clear a field and submit:
PairUI.delegate(document, 'click', '[data-clear-search]', (event, button) => {
event.preventDefault();
const form = PairUI.formRoot(button, null);
const input = form ? PairUI.qs('input[name="text-search"]', form) : null;
if (input) {
input.value = '';
}
PairUI.submit(form);
});PairUI.html escapes interpolated values by default.
const html = PairUI.html`
<option value="${42}">${'Premium plan'}</option>
`;Rendering a list:
const items = [
{ id: 1, title: 'Alpha' },
{ id: 2, title: 'Beta' }
];
const listHtml = PairUI.html`
<ul>
${items.map((item) => PairUI.html`<li data-id="${item.id}">${item.title}</li>`)}
</ul>
`;Allowing trusted HTML explicitly:
const trustedBadge = PairUI.raw('<span class="status-ok">OK</span>');
const html = PairUI.html`<div>${trustedBadge}</div>`;Building a select safely:
function buildOptions(rows) {
return PairUI.html`
<option value="">- Select -</option>
${rows.map((row) => PairUI.html`
<option value="${row.id}">${row.label}</option>
`)}
`;
}PairUI.withLoading() and PairUI.run() remove a lot of async-button boilerplate.
PairUI.toast provides a driver-agnostic toast API that follows the application
driver configured by Pair.
Basic usage:
PairUI.toast.success('Saved', 'User updated');
PairUI.toast.error({ title: 'Error', message: 'Unable to save user' });
PairUI.toast.show({
type: 'warning',
title: 'Attention',
message: 'Background sync is delayed',
position: 'top-end',
timer: 2500
});Available helpers:
PairUI.toast.show(options)PairUI.toast.info(titleOrOptions, message?, options?)PairUI.toast.success(titleOrOptions, message?, options?)PairUI.toast.warning(titleOrOptions, message?, options?)PairUI.toast.error(titleOrOptions, message?, options?)PairUI.toast.question(titleOrOptions, message?, options?)PairUI.toast.getDriver()PairUI.toast.getConfig()PairUI.toast.configure(options)
Supported position aliases include both iziToast and SweetAlert2 naming styles:
topRight, top-end, bottomLeft, bottom-start, center, and similar.
const button = PairUI.qs('#save-button');
await PairUI.withLoading(button, async () => {
await PairUI.http.postJson('/api/save', { id: 10 });
});<button id="archive-button" type="button">
<span class="button-icon"></span>
<span class="label">Archive</span>
</button>const button = PairUI.qs('#archive-button');
await PairUI.withLoading(button, async () => {
await PairUI.http.postJson('/api/archive', { id: 12 });
}, {
iconSelector: '.button-icon',
loadingIconClass: 'is-loading-icon',
textSelector: '.label',
text: 'Archiving...'
});await PairUI.run(async () => {
await PairUI.http.postJson('/api/rebuild', {});
}, {
target: PairUI.qs('#rebuild-button'),
iconSelector: '.button-icon',
loadingIconClass: 'is-spinning'
});const submitButton = PairUI.qs('[data-role="tier-submit"]');
const form = PairUI.qs('form[data-role="tier-form"]');
await PairUI.withLoading(submitButton, async () => {
const response = await PairUI.http.postForm('/targets/saveTier', form);
if (response && response.redirectUrl) {
window.location.href = response.redirectUrl;
return;
}
window.location.reload();
}, {
iconSelector: '.button-icon',
loadingIconClass: 'is-spinning'
});The http API remains small, but supports richer request options.
const rows = await PairUI.http.get('/api/users/search', {
query: { q: 'mar', active: true }
});This produces a URL similar to:
/api/users/search?q=mar&active=trueconst payload = await PairUI.http.postJson('/api/users', {
email: 'ada@example.com',
enabled: true
});Equivalent lower-level form:
const payload = await PairUI.http.request('/api/users', {
method: 'POST',
json: { email: 'ada@example.com' }
});await PairUI.http.postForm('/api/orders', {
productId: 10,
quantity: 2
});const form = PairUI.qs('#profile-form');
await PairUI.http.postForm('/profiles/save', form);await PairUI.http.request('/api/export', {
method: 'POST',
json: { month: '2026-03' },
headers: {
Accept: 'application/json'
}
});const response = await PairUI.http.request('/files/report.pdf', {
method: 'GET',
expect: 'response'
});
const blob = await response.blob();const blob = await PairUI.http.request('/files/report.pdf', {
method: 'GET',
expect: 'blob'
});PairUI.http.request() throws an Error whose message is already normalized from the backend payload when possible.
try {
await PairUI.http.postJson('/api/users', { email: '' });
} catch (error) {
const message = typeof error?.message === 'string' && error.message
? error.message
: 'Unable to save user.';
console.error(message, {
status: error?.status,
payload: error?.payload
});
}const url = PairUI.http.buildUrl('/api/logs', {
page: 2,
level: 'warning'
});const formData = PairUI.http.formData({
title: 'March report',
tags: ['sales', 'approved']
});const formData = PairUI.http.formData({
title: 'Avatar',
meta: { crop: 'square' },
file: fileInput.files[0]
});PairUI.form.autosave(form, options) adds implicit background saving to a
server-rendered form. It listens to input, focusout and change, sends the
form through PairUI.http.postForm(), retries failed saves, updates the CSRF
token when the backend returns csrfToken, and can perform a best-effort save
with sendBeacon() when the page unloads.
<form method="post" data-pair-autosave-url="/leads/autoSaveWizard">
<input type="hidden" name="csrf_token" value="...">
<input name="customerEmail" type="email">
<select name="callOutcome"></select>
<div data-pair-autosave-status aria-live="polite"></div>
<a href="/leads" data-pair-autosave-exit>Back</a>
</form>PairUI.ready(() => {
const form = PairUI.qs('[data-pair-autosave-url]');
const autosave = PairUI.form.autosave(form, {
beforeSave(root) {
// synchronize derived hidden fields before FormData is built
},
successMessage(data) {
return data.completed ? 'Saved.' : 'Draft saved.';
},
defaultErrorMessage: 'The value was not saved after two attempts.'
});
autosave.bind();
});The helper is intentionally transport-oriented. Domain validation, ownership
checks and draft/final-save decisions still belong to the Pair controller and
model. Backend endpoints should return Pair JSON responses with dataResponse()
or Problem Details through problemResponse().
Useful options:
-
url: explicit POST URL; if omitted,data-pair-autosave-urlis read from the form -
beforeSave(root, controller): hook called before buildingFormData -
successMessage: string or callback receiving(data, response, controller) -
defaultErrorMessageandtechnicalErrorMessage: fallback user-facing messages -
statusSelector: defaults to[data-pair-autosave-status] -
exitSelector: defaults to[data-pair-autosave-exit] -
retryAttempts: defaults to2 -
select2Events: defaults tofalse; opt in only when a page already uses Select2
The controller returned by PairUI.form.autosave() exposes:
bind()bindControl(control)schedule(control?, immediate?)flush()waitForIdle()markDirty(control?)setDirty(dirty)
It also emits these bubbling events from the form:
pair:autosave:savingpair:autosave:savedpair:autosave:error
PairUI includes opt-in helpers for SPA-like interactions on server-rendered pages. They do not start automatically and do not replace Pair routing.
Use data-pair-region to mark a replaceable region:
<section
data-pair-region="orders-list"
data-pair-source="/orders/default">
<!-- server-rendered listing -->
</section>Refresh the region from the same server-rendered action:
await PairUI.region.refresh('orders-list');The request includes:
X-Pair-UI: 1
X-Pair-Region: orders-list
X-Requested-With: XMLHttpRequestThe response should include markup with a matching data-pair-region. PairUI
replaces only that region, preserves focus when the same control still exists,
sets aria-busy during the request, aborts stale refreshes for the same region
and emits:
-
pair:region:refreshingbefore the request; this event is cancelable -
pair:region:replacedafter a successful replacement -
pair:region:errorafter a non-aborted refresh error
Progressive form submission is also opt-in:
<form
action="/orders/updateStatus"
method="post"
data-pair-submit
data-pair-refresh="orders-list">
<!-- fields and CSRF token -->
</form>
<script>
PairUI.ready(() => {
PairUI.actions.bind(document);
});
</script>PairUI.actions.bind() intercepts only form[data-pair-submit] and
[data-pair-action]. It submits FormData, shows a toast when a Pair JSON
response contains a message and refreshes the regions declared in
data-pair-refresh.
Call PairUI.actions.submit(form, { refresh: false }) or
PairUI.actions.run(button, { refresh: false }) when a custom caller should skip
all automatic region refreshes, including regions declared by the JSON payload.
Responses can also declare refresh regions:
{
"data": {
"refreshRegions": ["orders-list"],
"refreshUrl": "/orders/default"
},
"meta": {
"message": "Order updated"
}
}Persistent filters can update a listing region without a full reload:
<select
name="statusFilter"
data-pair-filter
data-pair-state-name="statusFilter"
data-pair-refresh="orders-list"
data-pair-value-type="string">
<option value="">All statuses</option>
<option value="open">Open</option>
</select>
<script>
PairUI.ready(() => {
PairUI.filters.bind(document);
});
</script>PairUI.filters.bind() writes Pair-style persistent-state cookies through
PairUI.persist, toggles active-filter and refreshes declared regions. If a
progressive refresh fails, the helper falls back to the canonical form action or
the current path so the server-rendered behavior remains available.
On the PHP side, Pair v4 controllers can pair these helpers with fragment responses:
return $this->pageOrFragment(
'default',
'_list',
$state,
'orders-list',
'Orders'
);For pages with multiple replaceable regions, use a region-to-layout map:
return $this->pageOrFragments('default', [
'orders-list' => '_list',
'orders-summary' => '_summary',
], $state, 'Orders');Normal browser requests still receive the full page. Fragment responses are
returned only when the request explicitly asks for a matching X-Pair-Region.
PairUI.persist reads and writes Pair-style persistent state cookies using the same naming convention as the backend:
<cookiePrefix> + ucfirst(stateName)PairUI.persist.configure({
cookiePrefix: 'ep_',
days: 30,
path: '/',
sameSite: 'Lax'
});Or through a global config before your page boot:
<script>
window.PairUIPersistConfig = {
cookiePrefix: 'ep_',
days: 30
};
</script>PairUI.persist.set('categoryFilter', 5);
PairUI.persist.set('auditDateFromFilter', '2026-03-01');
PairUI.persist.set('showArchived', true);const categoryId = PairUI.persist.get('categoryFilter');
const showArchived = PairUI.persist.get('showArchived');PairUI.persist.unset('categoryFilter');PairUI.persist.bind('select.category-filter', 'categoryFilter', {
normalize(value) {
return value === '' ? null : parseInt(value, 10);
}
});PairUI.persist.bind('input.audit-search-filter', 'auditSearchFilter', {
event: 'input',
reload: false
});PairUI.persist.bind('input[name="scoreMin"]', 'scoreMinFilter', {
normalize(value) {
return value === '' ? null : parseInt(value, 10);
},
shouldUnset(value) {
return value == null || Number.isNaN(value) || value <= 0;
}
});PairUI.persist.bind('select[name="locale"]', 'localeFilter', {
afterChange(value) {
document.body.dataset.locale = value || '';
}
});<span data-text="user.name"></span>Writes escaped text content from the store.
<div data-html="cardHtml"></div>Writes HTML from the store. Use only with trusted HTML.
<div data-show="isVisible">Visible when truthy</div>Toggles display: none.
<div data-if="isAdmin">Admin only</div>Removes or reinserts the node through a placeholder comment.
<button data-class="is-loading:loading is-active:selected"></button>Supports comma-separated and space-separated class:expr pairs.
<a data-attr="href:url title:user.name"></a>Assigns HTML attributes from store expressions.
<input data-prop="disabled:loading checked:isEnabled">Assigns DOM properties.
<div data-style="opacity:opacityValue display:displayValue"></div>Assigns inline styles.
<input data-model="filters.query">Two-way binding for text, checkbox and radio inputs.
<button data-on="click:save($user.id, 'profile')">Save</button>Calls a registered store action with resolved arguments.
<ul data-each="items" data-each-item="item" data-each-index="index">
<template>
<li>
<span data-text="index"></span>
<strong data-text="item.name"></strong>
</li>
</template>
</ul>Repeats template content for each item in the target collection.
const store = PairUI.createStore(
{ count: 0, tax: 22, total: 100 },
{
totalWithTax(state) {
return state.total + ((state.total * state.tax) / 100);
}
}
);Subscribing to changes:
const unsubscribe = store.subscribe(({ state }) => {
console.log(state.count);
});Patching values:
store.patch({ count: 5 });
store.set('count', 6);const store = PairUI.createStore({ isOpen: false });
const unmount = PairUI.mount(document.getElementById('drawer'), store);const app = PairUI.createApp({
root: document.getElementById('profile-box'),
state: {
saving: false,
profile: {
name: 'Ada'
}
},
actions: {
async save({ store }) {
store.state.saving = true;
try {
await PairUI.http.postJson('/api/profile', store.state.profile);
} finally {
store.state.saving = false;
}
}
}
});const cleanup = PairUI.island('[data-user-card]', (root) => {
const button = PairUI.qs('[data-toggle-details]', root);
function handleClick() {
root.classList.toggle('is-open');
}
PairUI.on(button, 'click', handleClick);
return () => {
PairUI.off(button, 'click', handleClick);
};
});PairUI.use((ui) => {
ui.formatCurrency = (value) => new Intl.NumberFormat('it-IT', {
style: 'currency',
currency: 'EUR'
}).format(value);
});
console.log(PairUI.formatCurrency(10));const input = PairUI.qs('[data-role="user-search"]');
const results = PairUI.qs('[data-role="user-search-results"]');
PairUI.on(input, 'input', PairUI.debounce(async (event) => {
const query = event.currentTarget.value.trim();
if (!query) {
results.innerHTML = '';
return;
}
const rows = await PairUI.http.get('/api/users/search', {
query: { q: query }
});
results.innerHTML = PairUI.html`
${rows.map((row) => PairUI.html`
<li data-id="${row.id}">${row.label}</li>
`)}
`;
}, 250));const button = PairUI.qs('[data-role="profile-save"]');
const form = PairUI.qs('[data-role="profile-form"]');
PairUI.on(button, 'click', async () => {
try {
await PairUI.withLoading(button, async () => {
await PairUI.http.postForm('/profile/save', form);
}, {
textSelector: '.label',
text: 'Saving...'
});
window.location.reload();
} catch (error) {
console.error(error.message || 'Unable to save the profile.');
}
});PairUI.persist.configure({ cookiePrefix: 'ep_' });
PairUI.persist.bind('select.category-filter', 'categoryFilter', {
normalize(value) {
return value === '' ? null : parseInt(value, 10);
}
});
PairUI.persist.bind('select.audit-event-filter', 'auditEventFilter');
PairUI.persist.bind('input.audit-date-from-filter', 'auditDateFromFilter');
PairUI.persist.bind('input.audit-date-to-filter', 'auditDateToFilter');PairUI.delegate(document, 'click', '[data-search-submit]', (event, button) => {
event.preventDefault();
PairUI.submit(button);
});function buildAliasSelectHtml(aliases) {
return PairUI.html`
<div class="row mb-4">
<label>Title</label>
<select class="form-control" name="aliasId">
${aliases.map((alias) => PairUI.html`
<option value="${alias.id}">${alias.title}</option>
`)}
</select>
</div>
`;
}PairUI.delegate(document, 'change', 'input[type="file"][data-upload-url]', async (event, input) => {
const button = input.closest('.upload-box')?.querySelector('button');
try {
await PairUI.withLoading(button, async () => {
await PairUI.http.postForm(input.dataset.uploadUrl, {
file: input.files[0]
});
}, {
textSelector: '.label',
text: 'Uploading...'
});
window.location.reload();
} catch (error) {
console.error(error.message || 'Unable to upload the file.');
}
});Producer:
PairUI.delegate(document, 'change', '[data-role="orders-filter"]', (event, field) => {
PairUI.emit(field, 'pair:orders-filter-changed', {
name: field.name,
value: field.value
});
});Consumer:
PairUI.on(document, 'pair:orders-filter-changed', (event) => {
console.log(event.detail);
});PairUI.island('[data-copy-box]', (root) => {
const button = PairUI.qs('[data-copy]', root);
const source = PairUI.qs('[data-copy-source]', root);
async function handleCopy() {
await navigator.clipboard.writeText(source.textContent || '');
root.dataset.copied = '1';
}
PairUI.on(button, 'click', handleCopy);
return () => {
PairUI.off(button, 'click', handleCopy);
};
});-
PairUI.http.request()errors exposemessage,status,payloadandresponse. -
PairUI.persistis designed mainly for scalar values commonly used in filters and lightweight client state. -
PairUI.htmlescapes interpolations by default; usePairUI.raw()only with trusted HTML. -
data-class,data-attr,data-propanddata-styleaccept both comma-separated and space-separated pairs such asa:b c:d.
See also: Application, AppTrait, PWA.