Skip to content

PairUI.js

Viames Marino edited this page Mar 31, 2026 · 4 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
  • Pair-style persistent state cookies
  • driver-aware toast notifications through PairUI.toast

Current version:

console.log(PairUI.version); // "0.4.0"

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

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