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 JSONAn 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.
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 withIntl.PluralRulesaccording tocount. - Regional languages:
es-MXfalls back toesand then to the default language. - Lazy loading:
await i18n.load('fr', () => import('./fr.json')). - Formats:
number(),date()andrelative()follow the language. - Pipes:
i18n.installPipes()registerst,numanddate:{{ 'items' | t:n() }}.
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);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; }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);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.