Skip to content

PairUI.js

Viames Marino edited this page Jun 18, 2026 · 6 revisions

Pair framework: 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"

1. Mental model

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.

2. Installation

<script src="/assets/PairUI.js"></script>

Available globals:

window.PairUI
window.Pair.UI

Toast 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.

3. Quick start

<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>

4. Core utilities

4.1 DOM helpers

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);
});

4.2 Event helpers

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');
}

4.3 Timing helpers

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));

4.4 Submit helper

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);
});

4.5 Escaped HTML templates

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>
    `)}
  `;
}

5. Loading helpers

PairUI.withLoading() and PairUI.run() remove a lot of async-button boilerplate.

5.1 Toast helpers

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.

5.1 Basic button lock

const button = PairUI.qs('#save-button');

await PairUI.withLoading(button, async () => {
  await PairUI.http.postJson('/api/save', { id: 10 });
});

5.2 Button with icon and label swap

<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...'
});

5.3 Task-first wrapper

await PairUI.run(async () => {
  await PairUI.http.postJson('/api/rebuild', {});
}, {
  target: PairUI.qs('#rebuild-button'),
  iconSelector: '.button-icon',
  loadingIconClass: 'is-spinning'
});

5.4 Full save flow

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'
});

6. HTTP helpers

The http API remains small, but supports richer request options.

6.1 GET with query params

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=true

6.2 POST JSON

const 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' }
});

6.3 POST form object

await PairUI.http.postForm('/api/orders', {
  productId: 10,
  quantity: 2
});

6.4 POST an actual HTML form

const form = PairUI.qs('#profile-form');
await PairUI.http.postForm('/profiles/save', form);

6.5 Custom fetch options

await PairUI.http.request('/api/export', {
  method: 'POST',
  json: { month: '2026-03' },
  headers: {
    Accept: 'application/json'
  }
});

6.6 Expecting a raw Response

const response = await PairUI.http.request('/files/report.pdf', {
  method: 'GET',
  expect: 'response'
});

const blob = await response.blob();

6.7 Expecting a blob directly

const blob = await PairUI.http.request('/files/report.pdf', {
  method: 'GET',
  expect: 'blob'
});

6.8 Handling errors once

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
  });
}

6.9 Building URLs manually when needed

const url = PairUI.http.buildUrl('/api/logs', {
  page: 2,
  level: 'warning'
});

6.10 Building FormData

const formData = PairUI.http.formData({
  title: 'March report',
  tags: ['sales', 'approved']
});

6.11 Appending files and nested values

const formData = PairUI.http.formData({
  title: 'Avatar',
  meta: { crop: 'square' },
  file: fileInput.files[0]
});

6.12 Automatic form autosave

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-url is read from the form
  • beforeSave(root, controller): hook called before building FormData
  • successMessage: string or callback receiving (data, response, controller)
  • defaultErrorMessage and technicalErrorMessage: fallback user-facing messages
  • statusSelector: defaults to [data-pair-autosave-status]
  • exitSelector: defaults to [data-pair-autosave-exit]
  • retryAttempts: defaults to 2
  • select2Events: defaults to false; 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:saving
  • pair:autosave:saved
  • pair:autosave:error

6.13 Progressive regions, actions and filters

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: XMLHttpRequest

The 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:refreshing before the request; this event is cancelable
  • pair:region:replaced after a successful replacement
  • pair:region:error after 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.

7. Persistent state helpers

PairUI.persist reads and writes Pair-style persistent state cookies using the same naming convention as the backend:

<cookiePrefix> + ucfirst(stateName)

7.1 Configure once

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>

7.2 Set a value

PairUI.persist.set('categoryFilter', 5);
PairUI.persist.set('auditDateFromFilter', '2026-03-01');
PairUI.persist.set('showArchived', true);

7.3 Get a value

const categoryId = PairUI.persist.get('categoryFilter');
const showArchived = PairUI.persist.get('showArchived');

7.4 Unset a value

PairUI.persist.unset('categoryFilter');

7.5 Bind a select and reload automatically

PairUI.persist.bind('select.category-filter', 'categoryFilter', {
  normalize(value) {
    return value === '' ? null : parseInt(value, 10);
  }
});

7.6 Bind a text input without reload

PairUI.persist.bind('input.audit-search-filter', 'auditSearchFilter', {
  event: 'input',
  reload: false
});

7.7 Bind with custom empty-state logic

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;
  }
});

7.8 Bind and run extra logic before reload

PairUI.persist.bind('select[name="locale"]', 'localeFilter', {
  afterChange(value) {
    document.body.dataset.locale = value || '';
  }
});

8. Directive reference

8.1 data-text

<span data-text="user.name"></span>

Writes escaped text content from the store.

8.2 data-html

<div data-html="cardHtml"></div>

Writes HTML from the store. Use only with trusted HTML.

8.3 data-show

<div data-show="isVisible">Visible when truthy</div>

Toggles display: none.

8.4 data-if

<div data-if="isAdmin">Admin only</div>

Removes or reinserts the node through a placeholder comment.

8.5 data-class

<button data-class="is-loading:loading is-active:selected"></button>

Supports comma-separated and space-separated class:expr pairs.

8.6 data-attr

<a data-attr="href:url title:user.name"></a>

Assigns HTML attributes from store expressions.

8.7 data-prop

<input data-prop="disabled:loading checked:isEnabled">

Assigns DOM properties.

8.8 data-style

<div data-style="opacity:opacityValue display:displayValue"></div>

Assigns inline styles.

8.9 data-model

<input data-model="filters.query">

Two-way binding for text, checkbox and radio inputs.

8.10 data-on

<button data-on="click:save($user.id, 'profile')">Save</button>

Calls a registered store action with resolved arguments.

8.11 data-each

<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.

9. Store and app helpers

9.1 PairUI.createStore()

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);

9.2 PairUI.mount()

const store = PairUI.createStore({ isOpen: false });
const unmount = PairUI.mount(document.getElementById('drawer'), store);

9.3 PairUI.createApp()

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;
      }
    }
  }
});

9.4 PairUI.island()

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);
  };
});

9.5 PairUI.use()

PairUI.use((ui) => {
  ui.formatCurrency = (value) => new Intl.NumberFormat('it-IT', {
    style: 'currency',
    currency: 'EUR'
  }).format(value);
});

console.log(PairUI.formatCurrency(10));

10. Cookbook

10.1 Debounced live search

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));

10.2 Save button with loading state and request handling

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.');
  }
});

10.3 Server-rendered filters with persistent state

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');

10.4 Search button that submits the form

PairUI.delegate(document, 'click', '[data-search-submit]', (event, button) => {
  event.preventDefault();
  PairUI.submit(button);
});

10.5 Build HTML without manual escaping

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>
  `;
}

10.6 Upload action with loading state

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.');
  }
});

10.7 Event bridge between isolated components

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);
});

10.8 Reusable island bootstrap

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);
  };
});

11. Notes

  • PairUI.http.request() errors expose message, status, payload and response.
  • PairUI.persist is designed mainly for scalar values commonly used in filters and lightweight client state.
  • PairUI.html escapes interpolations by default; use PairUI.raw() only with trusted HTML.
  • data-class, data-attr, data-prop and data-style accept both comma-separated and space-separated pairs such as a:b c:d.

See also: Application, AppTrait, PWA.

Clone this wiki locally