Angelitedocs ENES

Home › Learn

HTTP, i18n and animations

HTTP client with interceptors and resource(), internationalization with plurals, and enter and leave animations.

HTTP client and data#

createHttp() (@duvanjamid/angelite/http) is a client on top of fetch: base URL, query parameters, automatic JSON, timeout, cancellation with AbortSignal and interceptors. Responses that are not 2xx throw an HttpError with status and data.

import { createHttp, retry } from '@duvanjamid/angelite/http';

const http = createHttp({
  baseUrl: '/api',
  headers: { 'X-App': 'demo' },
  interceptors: [
    (req, next) => next({ ...req, headers: { ...req.headers, Authorization: `Bearer ${token()}` } }),
    retry(2)                                   // retries network errors and 5xx
  ]
});

const user  = await http.get('/users/1', { params: { fields: 'name' }, timeout: 5000 });
await http.post('/users', { name: 'Ada' });    // objects are sent as JSON

An interceptor is (request, next) => response: it can change the request, the response, or answer without going to the network (a cache, for example). They chain: the first in the list is the first to see the request and the last to see the response.

resource(): data in signals#

resource(() => promise) keeps data, loading and error in signals, reloads when a signal read by the loader changes, and ignores late responses from old requests (if you go from page 2 to page 3, a slow response for page 2 does not overwrite page 3). reload() forces another load and destroy() stops it.

Interceptors and resource() (with a simulated fetch)live
View the code
const { signal, component, mount, createHttp, resource } = AngeLite;

// a fake "server" for the example
const server = async (url, init) => {
  await new Promise(r => setTimeout(r, 300));
  const page = new URL(url, 'http://x').searchParams.get('page');
  return new Response(JSON.stringify({ page, auth: init.headers.Authorization, items: ['a' + page, 'b' + page] }), { status: 200 });
};

const http = createHttp({
  baseUrl: '/api',
  fetch: server,
  interceptors: [(req, next) => next({ ...req, headers: { ...req.headers, Authorization: 'Bearer demo' } })]
});

class List {
  page = signal(1);
  data = resource(() => http.get('/items', { params: { page: this.page() } }));   // reloads on its own when the page changes
}
component(List, {
  template: `
    <button (click)="page.set(page() + 1)">Page {{ page() }} → next</button>
    @if (data.loading()) {<p>Loading…</p>}
    @else if (data.error()) {<p class="err">Error</p>}
    @else {<p>{{ data.data().items.join(', ') }} · {{ data.data().auth }}</p>}`
});

mount(List, root);

Internationalization#

createI18n() (@duvanjamid/angelite/i18n) keeps the active language in a signal: any template that calls t() repaints when it changes.

  • Parameters: t('hello', { name: 'Ada' }) with {name} in the message. A missing parameter stays visible.
  • Plurals: a message can be an object with zero, one, few, many, other…; the right one is chosen with Intl.PluralRules according to count.
  • Regional languages: es-MX falls back to es and then to the default language.
  • Lazy loading: await i18n.load('fr', () => import('./fr.json')).
  • Formats: number(), date() and relative() follow the language.
  • Pipes: i18n.installPipes() registers t, num and date: {{ 'items' | t:n() }}.
Languages and pluralslive
View the code
const { signal, component, mount, createI18n } = AngeLite;

const i18n = createI18n({ locale: 'en', fallback: 'es', messages: {
  en: { hello: 'Hello', items: { one: '{count} item', other: '{count} items' } },
  es: { hello: 'Hola', items: { one: '{count} elemento', other: '{count} elementos' } }
} });
i18n.installPipes();

class App {
  i18n = i18n;
  n = signal(1);
}
component(App, {
  template: `
    <button (click)="i18n.setLocale(i18n.locale() === 'en' ? 'es' : 'en')">{{ i18n.locale() }}</button>
    <button (click)="n.set(n() + 1)">+1</button>
    <p>{{ 'hello' | t }} · {{ 'items' | t:n() }} · {{ 1234.5 | num }}</p>`
});

mount(App, root);
Messages are text

Even if a message or a parameter contains HTML, it is shown as text: it cannot inject markup. If you need bold inside a sentence, split the message into parts or use a component.

Enter and leave animations#

With animate.enter="class" and animate.leave="class" an element receives that CSS class when created, and when it disappears from an @if or a @for it stays in the DOM until its animation or transition ends (with a safety cap so it never gets stuck). With prefers-reduced-motion there is no animation. You write the classes in CSS:

@keyframes enter { from { opacity: 0; transform: translateY(-6px); } }
.fade-in  { animation: enter .3s ease both; }
.fade-out { animation: enter .3s ease reverse both; }
animate.enter / animate.leavelive
View the code
const { signal, component, mount } = AngeLite;

class List {
  items = signal([1, 2, 3]);
  next = 4;
  add() { this.items.update(l => [...l, this.next++]); }
  remove(n) { this.items.update(l => l.filter(x => x !== n)); }
}
component(List, {
  template: `
    <button (click)="add()">Add</button>
    <ul>@for (n of items(); track n) {
      <li animate.enter="fade-in" animate.leave="fade-out">Row {{ n }} <button (click)="remove(n)">×</button></li>
    }</ul>`
});

mount(List, root);
Scope

This is the minimum that is useful for entering and leaving. There are no route transitions, no animated list reordering (FLIP) and no state orchestration.