-
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
- Pair-style persistent state cookies
- driver-aware toast notifications through
PairUI.toast
Current version:
console.log(PairUI.version); // "0.4.0"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.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.