mates 0.1.0-beta.2 → 0.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1623
- package/dist/index.d.ts +397 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.js +20 -20
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +28 -28
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
**MATES** is a lightweight, TypeScript-first front-end framework built on [lit-html](https://lit.dev/docs/libraries/standalone-templates/) to help you build large scale complex web applications that focuses on Developer Experience.
|
|
4
4
|
|
|
5
|
+
Try out Here on Stackblitz
|
|
6
|
+
Check the Full **Docs** Here
|
|
7
|
+
|
|
5
8
|
What if we had a framework that is just perfect? A framework that's faster than react and without the need of a compiler. A Framework that's super type safe. A framework that cherishes capabilities of Javascript. Mates a Beautiful, simple, Perfect Javascript Framework with **no virtual DOM**, **no compiler step**, but with lots of **goodness**. You can install Mates and run using a build tool like Vite or Bun or just use CDN to load Mates at runtime.
|
|
6
9
|
|
|
7
10
|
MATES is based on an architecture: **M**utable State, **A**ctions, **T**emplates, **E**vents and **S**etups. State is mutable, Actions are trackable functions, Templates are functions that return html template strings, setups are run once before the component is mounted to set up some state or some business logic or effects.
|
|
@@ -10,47 +13,6 @@ Mates is about 50Kb gzipped, but it comes with a Router, really good state manag
|
|
|
10
13
|
|
|
11
14
|
---
|
|
12
15
|
|
|
13
|
-
## Table of Contents
|
|
14
|
-
|
|
15
|
-
- [Why Mates?](#why-mates)
|
|
16
|
-
- [Installation](#installation)
|
|
17
|
-
- [Quick Start](#quick-start)
|
|
18
|
-
- [Components](#components)
|
|
19
|
-
- [The Two-Layer Model](#the-two-layer-model)
|
|
20
|
-
- [Rendering Components](#rendering-components)
|
|
21
|
-
- [Props](#props)
|
|
22
|
-
- [Children / Slots](#children--slots)
|
|
23
|
-
- [State Management](#state-management)
|
|
24
|
-
- [atom — reactive primitive](#atom--reactive-primitive)
|
|
25
|
-
- [iAtom — immutable signal](#iatom--immutable-signal)
|
|
26
|
-
- [effect — reactive side effect](#effect--reactive-side-effect)
|
|
27
|
-
- [memo — derived / computed value](#memo--derived--computed-value)
|
|
28
|
-
- [useState — local object state](#usestate--local-object-state)
|
|
29
|
-
- [store — module-level shared state](#store--module-level-shared-state)
|
|
30
|
-
- [setAtom — reactive Set](#setatom--reactive-set)
|
|
31
|
-
- [mapAtom — reactive Map](#mapatom--reactive-map)
|
|
32
|
-
- [lsAtom / ssAtom — persistent state](#lsatom--ssatom--persistent-state)
|
|
33
|
-
- [Scopes — Shared State Without Prop Drilling](#scopes--shared-state-without-prop-drilling)
|
|
34
|
-
- [Lifecycle Hooks](#lifecycle-hooks)
|
|
35
|
-
- [DOM & Window Hooks](#dom--window-hooks)
|
|
36
|
-
- [Async Actions](#async-actions)
|
|
37
|
-
- [asyncAction](#asyncaction)
|
|
38
|
-
- [action — synchronous action](#action--synchronous-action)
|
|
39
|
-
- [paginatedAsyncAction](#paginatedasyncaction)
|
|
40
|
-
- [taskAction](#taskaction)
|
|
41
|
-
- [HTTP Client](#http-client)
|
|
42
|
-
- [Routing](#routing)
|
|
43
|
-
- [CSS-in-JS & Theming](#css-in-js--theming)
|
|
44
|
-
- [Directives](#directives)
|
|
45
|
-
- [Portals, Dialogs & Tooltips](#portals-dialogs--tooltips)
|
|
46
|
-
- [Animations](#animations)
|
|
47
|
-
- [WebSocket](#websocket)
|
|
48
|
-
- [Virtualization](#virtualization)
|
|
49
|
-
- [DevTools](#devtools)
|
|
50
|
-
- [TypeScript](#typescript)
|
|
51
|
-
- [Features at a Glance](#features-at-a-glance)
|
|
52
|
-
- [How Mates Compares](#how-mates-compares)
|
|
53
|
-
- [License](#license)
|
|
54
16
|
|
|
55
17
|
---
|
|
56
18
|
|
|
@@ -155,1588 +117,6 @@ const App = () => () => html`
|
|
|
155
117
|
|
|
156
118
|
---
|
|
157
119
|
|
|
158
|
-
### plain state and settter
|
|
159
|
-
|
|
160
|
-
You don't need to create atoms to handle state for your application. The state can be anything, it can be a plain primitives or objects or sets or maps or something else, the reason we use atoms because they are trackable and you can derive computed values from them. But you can also use plain JS objects as state and you can setter functions to notify the component that something is changed and that it needs to update itself. Here is an example of the same counter without using atoms.
|
|
161
|
-
|
|
162
|
-
```typescript
|
|
163
|
-
import { setter, html } from "mates";
|
|
164
|
-
import type { Props } from "mates";
|
|
165
|
-
|
|
166
|
-
const Counter = (propsFn: Props<{ label: string }>) => {
|
|
167
|
-
let count = 0;
|
|
168
|
-
const incr = setter(()=>count++);
|
|
169
|
-
// ── Inner: runs every time the incr is called or parent component is re-rendered.
|
|
170
|
-
return () => html`
|
|
171
|
-
<p>${propsFn().label}: ${count}</p>
|
|
172
|
-
<button @click=${incr}>Increment</button>
|
|
173
|
-
`;
|
|
174
|
-
};
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
### PropsFn
|
|
178
|
-
|
|
179
|
-
Props are passed as the second argument to `x()` and arrive inside the component as a **function** — `propsFn()`. Calling it inside the **inner** function ensures you always get the latest values when the parent re-renders.
|
|
180
|
-
|
|
181
|
-
```typescript
|
|
182
|
-
import { html, x, renderX } from "mates";
|
|
183
|
-
import type { Props } from "mates";
|
|
184
|
-
|
|
185
|
-
const Greeting = (propsFn: Props<{ name: string; color?: string }>) => {
|
|
186
|
-
// ✅ Read props in the inner function — always up-to-date
|
|
187
|
-
return () => html`
|
|
188
|
-
<p style="color: ${propsFn().color ?? "black"}">
|
|
189
|
-
Hello, ${propsFn().name}!
|
|
190
|
-
</p>
|
|
191
|
-
`;
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
// Props update automatically when the parent re-renders
|
|
195
|
-
const App = () => {
|
|
196
|
-
const name = atom("World");
|
|
197
|
-
return () => html`
|
|
198
|
-
${x(Greeting, { name: name(), color: "royalblue" })}
|
|
199
|
-
<button @click=${() => name.set("Mates")}>Change Name</button>
|
|
200
|
-
`;
|
|
201
|
-
};
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
> ⚠️ Never destructure props in the outer function (`const { name } = propsFn()`). The value would be captured at mount time and never update.
|
|
205
|
-
|
|
206
|
-
---
|
|
207
|
-
|
|
208
|
-
### Children / Slots
|
|
209
|
-
|
|
210
|
-
Pass children using the `<x-view>` element and render them inside the component with a standard `<slot>` element:
|
|
211
|
-
|
|
212
|
-
```typescript
|
|
213
|
-
// Parent passes children
|
|
214
|
-
const App = () => () => html`
|
|
215
|
-
<x-view .view=${Card} .props=${{title: "Hello"}}>
|
|
216
|
-
<p>This is child content</p>
|
|
217
|
-
</x-view>
|
|
218
|
-
`;
|
|
219
|
-
|
|
220
|
-
// Component renders children via <slot>
|
|
221
|
-
const Card = (propsFn: Props<{ title: string }>) => () => html`
|
|
222
|
-
<div class="card">
|
|
223
|
-
<h2>${propsFn().title}</h2>
|
|
224
|
-
<slot></slot>
|
|
225
|
-
</div>
|
|
226
|
-
`;
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
---
|
|
230
|
-
|
|
231
|
-
## State Management
|
|
232
|
-
|
|
233
|
-
### `atom` — reactive primitive
|
|
234
|
-
|
|
235
|
-
`atom` is the core reactive primitive. It holds any JavaScript value. Reading an atom inside the **inner** function (or an `effect`) registers a dependency — that subscriber re-runs whenever the atom changes.
|
|
236
|
-
|
|
237
|
-
```typescript
|
|
238
|
-
import { atom } from "mates";
|
|
239
|
-
|
|
240
|
-
// Create
|
|
241
|
-
const username = atom("guest");
|
|
242
|
-
const count = atom(0);
|
|
243
|
-
const user = atom<{ name: string; age: number } | null>(null);
|
|
244
|
-
|
|
245
|
-
// Read
|
|
246
|
-
username(); // "guest" — registers reactive dependency
|
|
247
|
-
username.get(); // "guest" — alias, same behavior
|
|
248
|
-
username.val; // "guest" — non-reactive (snapshot, no tracking)
|
|
249
|
-
|
|
250
|
-
// Write
|
|
251
|
-
username.set("alice"); // replace
|
|
252
|
-
count.set((n) => n + 1); // updater function
|
|
253
|
-
user.set({ name: "Alice", age: 30 });
|
|
254
|
-
|
|
255
|
-
// Mutate objects in-place
|
|
256
|
-
const profile = atom({ name: "Alice", score: 0 });
|
|
257
|
-
profile.update((p) => { p.score++; }); // mutation, triggers update
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
**Atoms created inside a component's outer function** are scoped to that component — they are created on mount and automatically garbage-collected when the component unmounts.
|
|
261
|
-
|
|
262
|
-
**Atoms created at module level** are global — they persist for the lifetime of the application and can be shared between any component.
|
|
263
|
-
|
|
264
|
-
```typescript
|
|
265
|
-
// Global state — shared across all components
|
|
266
|
-
export const currentUser = atom<User | null>(null);
|
|
267
|
-
export const cartCount = atom(0);
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
---
|
|
271
|
-
|
|
272
|
-
### `iAtom` — immutable signal
|
|
273
|
-
|
|
274
|
-
`iAtom` (also exported as `signal`) behaves like `atom` but **deep-freezes every value** after each `set()`. Accidental mutations throw in strict mode, making state flows easier to reason about. There is no `.update()` method — you always replace the whole value.
|
|
275
|
-
|
|
276
|
-
```typescript
|
|
277
|
-
import { iAtom } from "mates";
|
|
278
|
-
|
|
279
|
-
const theme = iAtom<"light" | "dark">("light");
|
|
280
|
-
|
|
281
|
-
theme.set("dark");
|
|
282
|
-
theme(); // "dark"
|
|
283
|
-
|
|
284
|
-
const config = iAtom({ debug: false, version: 1 });
|
|
285
|
-
config.set({ debug: true, version: 2 }); // must replace entirely
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
---
|
|
289
|
-
|
|
290
|
-
### `effect` — reactive side effect
|
|
291
|
-
|
|
292
|
-
An effect runs **immediately** and automatically re-runs whenever any atom it reads during execution changes. Return a cleanup function to run before the next execution and on disposal.
|
|
293
|
-
|
|
294
|
-
```typescript
|
|
295
|
-
import { atom, effect } from "mates";
|
|
296
|
-
|
|
297
|
-
const count = atom(0);
|
|
298
|
-
const label = atom("counter");
|
|
299
|
-
|
|
300
|
-
// Runs immediately, then again whenever count or label changes
|
|
301
|
-
const stop = effect(() => {
|
|
302
|
-
document.title = `${label()}: ${count()}`;
|
|
303
|
-
|
|
304
|
-
// Optional: return a cleanup function
|
|
305
|
-
return () => {
|
|
306
|
-
document.title = "App";
|
|
307
|
-
};
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
count.set(5); // document.title → "counter: 5"
|
|
311
|
-
label.set("score"); // document.title → "score: 5"
|
|
312
|
-
|
|
313
|
-
stop(); // dispose — cleanup runs, no more re-runs
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
When used inside a component's outer function, effects are automatically disposed when the component unmounts.
|
|
317
|
-
|
|
318
|
-
---
|
|
319
|
-
|
|
320
|
-
### `memo` — derived / computed value
|
|
321
|
-
|
|
322
|
-
`memo` creates a derived atom that stays in sync with its dependencies. It only recomputes when a dependency changes. The result is itself a readable atom.
|
|
323
|
-
|
|
324
|
-
```typescript
|
|
325
|
-
import { atom, memo } from "mates";
|
|
326
|
-
|
|
327
|
-
const firstName = atom("Alice");
|
|
328
|
-
const lastName = atom("Smith");
|
|
329
|
-
|
|
330
|
-
const fullName = memo(() => `${firstName()} ${lastName()}`);
|
|
331
|
-
|
|
332
|
-
fullName(); // "Alice Smith"
|
|
333
|
-
firstName.set("Bob");
|
|
334
|
-
fullName(); // "Bob Smith" — recomputed automatically
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
---
|
|
338
|
-
|
|
339
|
-
### `useState` — local object state
|
|
340
|
-
|
|
341
|
-
`useState` is designed for local component state expressed as a plain object with data properties and setter methods. Every method call triggers a re-render.
|
|
342
|
-
|
|
343
|
-
```typescript
|
|
344
|
-
import { html, renderX, useState } from "mates";
|
|
345
|
-
|
|
346
|
-
const Counter = () => {
|
|
347
|
-
const [state] = useState({
|
|
348
|
-
count: 0,
|
|
349
|
-
step: 1,
|
|
350
|
-
incr() { this.count += this.step; },
|
|
351
|
-
decr() { this.count -= this.step; },
|
|
352
|
-
reset() { this.count = 0; },
|
|
353
|
-
setStep(n: number) { this.step = n; },
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
return () => html`
|
|
357
|
-
<div>
|
|
358
|
-
<button @click=${state.decr}>−</button>
|
|
359
|
-
<span>${state.count}</span>
|
|
360
|
-
<button @click=${state.incr}>+</button>
|
|
361
|
-
<button @click=${state.reset}>Reset</button>
|
|
362
|
-
<label>
|
|
363
|
-
Step:
|
|
364
|
-
<input
|
|
365
|
-
type="number"
|
|
366
|
-
.value=${String(state.step)}
|
|
367
|
-
@input=${(e: InputEvent) =>
|
|
368
|
-
state.setStep(Number((e.target as HTMLInputElement).value))}
|
|
369
|
-
/>
|
|
370
|
-
</label>
|
|
371
|
-
</div>
|
|
372
|
-
`;
|
|
373
|
-
};
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
> Async methods inside `useState` are automatically wrapped with `asyncAction`, giving you `.data`, `.isLoading`, `.error`, and `.status` atoms for free on async operations.
|
|
377
|
-
|
|
378
|
-
---
|
|
379
|
-
|
|
380
|
-
### `store` — module-level shared state
|
|
381
|
-
|
|
382
|
-
`store` creates a **global reactive container** from a plain object. Define it at module level and share it across the entire application. Inside a component, call the store to get a `[state, update]` tuple.
|
|
383
|
-
|
|
384
|
-
```typescript
|
|
385
|
-
import { store, html, x, renderX } from "mates";
|
|
386
|
-
|
|
387
|
-
// Define once at module level
|
|
388
|
-
const counterStore = store({
|
|
389
|
-
count: 0,
|
|
390
|
-
step: 1,
|
|
391
|
-
incr() { this.count += this.step; },
|
|
392
|
-
decr() { this.count -= this.step; },
|
|
393
|
-
get doubled() { return this.count * 2; },
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
// Consume in any component
|
|
397
|
-
const Counter = () => {
|
|
398
|
-
const [state, update] = counterStore();
|
|
399
|
-
|
|
400
|
-
return () => html`
|
|
401
|
-
<button @click=${state.decr}>−</button>
|
|
402
|
-
<span>${state.count} (doubled: ${state.doubled})</span>
|
|
403
|
-
<button @click=${state.incr}>+</button>
|
|
404
|
-
<button @click=${() => update((s) => { s.count = 0; })}>Reset</button>
|
|
405
|
-
`;
|
|
406
|
-
};
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
The `update` function is for direct external mutations. State methods (`incr`, `decr`) trigger re-renders automatically.
|
|
410
|
-
|
|
411
|
-
---
|
|
412
|
-
|
|
413
|
-
### `setAtom` — reactive Set
|
|
414
|
-
|
|
415
|
-
A reactive wrapper around JavaScript's `Set`. All mutations trigger component updates.
|
|
416
|
-
|
|
417
|
-
```typescript
|
|
418
|
-
import { setAtom } from "mates";
|
|
419
|
-
|
|
420
|
-
const selectedIds = setAtom<number>([1, 2]);
|
|
421
|
-
|
|
422
|
-
// Mutations — trigger re-renders
|
|
423
|
-
selectedIds.add(3);
|
|
424
|
-
selectedIds.delete(1);
|
|
425
|
-
selectedIds.clear();
|
|
426
|
-
|
|
427
|
-
// Reads — track as reactive dependencies
|
|
428
|
-
selectedIds.size; // 2
|
|
429
|
-
selectedIds.has(2); // true
|
|
430
|
-
selectedIds.values(); // IterableIterator
|
|
431
|
-
selectedIds.forEach((v) => {}); // iterate
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
---
|
|
435
|
-
|
|
436
|
-
### `mapAtom` — reactive Map
|
|
437
|
-
|
|
438
|
-
A reactive wrapper around JavaScript's `Map`. All mutations trigger component updates.
|
|
439
|
-
|
|
440
|
-
```typescript
|
|
441
|
-
import { mapAtom } from "mates";
|
|
442
|
-
|
|
443
|
-
const cache = mapAtom<string, number>([["apples", 3]]);
|
|
444
|
-
|
|
445
|
-
// Mutations
|
|
446
|
-
cache.set("bananas", 5);
|
|
447
|
-
cache.delete("apples");
|
|
448
|
-
cache.clear();
|
|
449
|
-
|
|
450
|
-
// Reads
|
|
451
|
-
cache.size; // 1
|
|
452
|
-
cache.get("bananas"); // 5
|
|
453
|
-
cache.has("bananas"); // true
|
|
454
|
-
cache.entries(); // IterableIterator<[string, number]>
|
|
455
|
-
```
|
|
456
|
-
|
|
457
|
-
---
|
|
458
|
-
|
|
459
|
-
### `lsAtom` / `ssAtom` — persistent state
|
|
460
|
-
|
|
461
|
-
`lsAtom` persists to `localStorage`; `ssAtom` persists to `sessionStorage`. Both auto-hydrate from storage on first read and automatically sync writes back.
|
|
462
|
-
|
|
463
|
-
```typescript
|
|
464
|
-
import { lsAtom, ssAtom } from "mates";
|
|
465
|
-
|
|
466
|
-
// Persisted across page reloads
|
|
467
|
-
const theme = lsAtom<"light" | "dark">("light");
|
|
468
|
-
const sidebar = lsAtom<boolean>(true);
|
|
469
|
-
|
|
470
|
-
// Session-only — cleared when the tab closes
|
|
471
|
-
const draftText = ssAtom<string>("");
|
|
472
|
-
|
|
473
|
-
theme.set("dark"); // saved to localStorage["mates"]
|
|
474
|
-
theme(); // "dark" — even after a page reload
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
`lsAtom` also syncs across browser tabs via the `storage` event.
|
|
478
|
-
|
|
479
|
-
---
|
|
480
|
-
|
|
481
|
-
## Scopes — Shared State Without Prop Drilling
|
|
482
|
-
|
|
483
|
-
Scopes let any **descendant** component access state from a parent without threading props through every layer in between. Define a scope as a class, initialize it in the parent with `useScope`, and read it anywhere below with `getParentScope`.
|
|
484
|
-
|
|
485
|
-
```typescript
|
|
486
|
-
import { atom, effect, getParentScope, html, onMount, repeat, useScope, x } from "mates";
|
|
487
|
-
import type { Props } from "mates";
|
|
488
|
-
|
|
489
|
-
// 1. Define the scope as a class
|
|
490
|
-
class CartScope {
|
|
491
|
-
items = atom<string[]>([]);
|
|
492
|
-
loading = atom(false);
|
|
493
|
-
|
|
494
|
-
add(item: string) {
|
|
495
|
-
this.items.update((list) => { list.push(item); });
|
|
496
|
-
}
|
|
497
|
-
remove(item: string) {
|
|
498
|
-
this.items.update((list) => {
|
|
499
|
-
const i = list.indexOf(item);
|
|
500
|
-
if (i !== -1) list.splice(i, 1);
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// setup() runs before the component mounts
|
|
505
|
-
setup() {
|
|
506
|
-
effect(() => {
|
|
507
|
-
console.log("Cart has", this.items().length, "items");
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// 2. Parent creates the scope
|
|
513
|
-
const Cart = () => {
|
|
514
|
-
const { items } = useScope(CartScope);
|
|
515
|
-
|
|
516
|
-
return () => html`
|
|
517
|
-
<div>
|
|
518
|
-
<p>${items().length} item(s) in cart</p>
|
|
519
|
-
${x(ProductList)}
|
|
520
|
-
${x(CartSummary)}
|
|
521
|
-
</div>
|
|
522
|
-
`;
|
|
523
|
-
};
|
|
524
|
-
|
|
525
|
-
// 3. Any descendant reads the scope — no props needed
|
|
526
|
-
const ProductList = () => {
|
|
527
|
-
const { add } = getParentScope(CartScope);
|
|
528
|
-
|
|
529
|
-
return () => html`
|
|
530
|
-
<ul>
|
|
531
|
-
<li><button @click=${() => add("Apple")}>Add Apple</button></li>
|
|
532
|
-
<li><button @click=${() => add("Banana")}>Add Banana</button></li>
|
|
533
|
-
</ul>
|
|
534
|
-
`;
|
|
535
|
-
};
|
|
536
|
-
|
|
537
|
-
const CartSummary = () => {
|
|
538
|
-
const { items, remove } = getParentScope(CartScope);
|
|
539
|
-
|
|
540
|
-
return () => html`
|
|
541
|
-
<ul>
|
|
542
|
-
${repeat(
|
|
543
|
-
items(),
|
|
544
|
-
(item) => item,
|
|
545
|
-
(item) => html`
|
|
546
|
-
<li>${item} <button @click=${() => remove(item)}>✕</button></li>
|
|
547
|
-
`,
|
|
548
|
-
)}
|
|
549
|
-
</ul>
|
|
550
|
-
`;
|
|
551
|
-
};
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
### `setup()` — scope initialization
|
|
555
|
-
|
|
556
|
-
Add a `setup()` method to a scope class to run initialization logic (effects, timers, subscriptions, lifecycle hooks) before the host component mounts:
|
|
557
|
-
|
|
558
|
-
```typescript
|
|
559
|
-
class TimerScope {
|
|
560
|
-
seconds = atom(0);
|
|
561
|
-
|
|
562
|
-
setup() {
|
|
563
|
-
// Lifecycle hooks work inside setup()
|
|
564
|
-
onMount(() => {
|
|
565
|
-
console.log("timer started");
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
onInterval(() => {
|
|
569
|
-
this.seconds.set((n) => n + 1);
|
|
570
|
-
}, 1000);
|
|
571
|
-
|
|
572
|
-
onCleanup(() => {
|
|
573
|
-
console.log("timer stopped");
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
// Effects are auto-disposed with the component
|
|
577
|
-
effect(() => {
|
|
578
|
-
document.title = `${this.seconds()}s elapsed`;
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
```
|
|
583
|
-
|
|
584
|
-
---
|
|
585
|
-
|
|
586
|
-
## Lifecycle Hooks
|
|
587
|
-
|
|
588
|
-
Lifecycle hooks must be called in the component's **outer function** (or in a scope's `setup()` method). They are automatically cleaned up when the component unmounts.
|
|
589
|
-
|
|
590
|
-
```typescript
|
|
591
|
-
import { atom, html, onMount, onCleanup, onPaint } from "mates";
|
|
592
|
-
|
|
593
|
-
const Timer = () => {
|
|
594
|
-
const seconds = atom(0);
|
|
595
|
-
|
|
596
|
-
// Runs after the component's first render
|
|
597
|
-
onMount(() => {
|
|
598
|
-
const id = setInterval(() => seconds.set((n) => n + 1), 1000);
|
|
599
|
-
|
|
600
|
-
// Return a cleanup function — called on unmount
|
|
601
|
-
return () => clearInterval(id);
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
// Runs after every browser paint (double-RAF)
|
|
605
|
-
onPaint(() => {
|
|
606
|
-
console.log("painted");
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
// Explicit cleanup — equivalent to returning a fn from onMount
|
|
610
|
-
onCleanup(() => {
|
|
611
|
-
console.log("component removed");
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
return () => html`<p>Elapsed: ${seconds()}s</p>`;
|
|
615
|
-
};
|
|
616
|
-
```
|
|
617
|
-
|
|
618
|
-
| Hook | Timing | Notes |
|
|
619
|
-
|------|--------|-------|
|
|
620
|
-
| `onMount(fn)` | After first render | Return a fn to run on cleanup |
|
|
621
|
-
| `onCleanup(fn)` | On unmount | Equivalent to `onMount` cleanup return |
|
|
622
|
-
| `onPaint(fn)` | After browser paint | Double RAF — element is measured |
|
|
623
|
-
| `onAllMount(fn)` | After all children mount | Safe for cross-component reads |
|
|
624
|
-
| `onError(fn)` | On component error | Receives the Error object |
|
|
625
|
-
|
|
626
|
-
---
|
|
627
|
-
|
|
628
|
-
## DOM & Window Hooks
|
|
629
|
-
|
|
630
|
-
These hooks attach listeners **scoped to the component**. They automatically detach when the component unmounts.
|
|
631
|
-
|
|
632
|
-
```typescript
|
|
633
|
-
import {
|
|
634
|
-
onDOMReady,
|
|
635
|
-
onKeyDown,
|
|
636
|
-
onWindowResize,
|
|
637
|
-
onVisibilityChange,
|
|
638
|
-
onInterval,
|
|
639
|
-
onTimeout,
|
|
640
|
-
onNavigate,
|
|
641
|
-
onClickAway,
|
|
642
|
-
onFileDrop,
|
|
643
|
-
onScroll,
|
|
644
|
-
onResize,
|
|
645
|
-
onStorageChange,
|
|
646
|
-
onOnline,
|
|
647
|
-
onOffline,
|
|
648
|
-
} from "mates";
|
|
649
|
-
|
|
650
|
-
const SearchBar = () => {
|
|
651
|
-
const query = atom("");
|
|
652
|
-
|
|
653
|
-
// Fires on every keydown anywhere on the page
|
|
654
|
-
onKeyDown((e) => {
|
|
655
|
-
if (e.key === "/" && !e.target.matches("input")) {
|
|
656
|
-
e.preventDefault();
|
|
657
|
-
inputRef.el?.focus();
|
|
658
|
-
}
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
// Fires when the browser window is resized
|
|
662
|
-
onWindowResize((e) => {
|
|
663
|
-
console.log("resized", window.innerWidth);
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
// Fires when the tab is hidden or shown
|
|
667
|
-
onVisibilityChange((hidden) => {
|
|
668
|
-
if (hidden) pauseSearch();
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
// setInterval — auto-cleared on unmount
|
|
672
|
-
onInterval(() => {
|
|
673
|
-
refreshResults();
|
|
674
|
-
}, 30_000);
|
|
675
|
-
|
|
676
|
-
// setTimeout — auto-cleared on unmount
|
|
677
|
-
onTimeout(() => {
|
|
678
|
-
showTip();
|
|
679
|
-
}, 2_000);
|
|
680
|
-
|
|
681
|
-
// Fires on every pathAtom change (client-side navigation)
|
|
682
|
-
onNavigate((path) => {
|
|
683
|
-
console.log("navigated to", path);
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
// Fires when a click happens outside the component's host element
|
|
687
|
-
onClickAway(() => {
|
|
688
|
-
closeDropdown();
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
// Fires when files are dragged + dropped onto the window
|
|
692
|
-
onFileDrop((files) => {
|
|
693
|
-
handleUpload(files);
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
// Network status
|
|
697
|
-
onOnline(() => syncPendingChanges());
|
|
698
|
-
onOffline(() => showOfflineBanner());
|
|
699
|
-
|
|
700
|
-
return () => html`...`;
|
|
701
|
-
};
|
|
702
|
-
```
|
|
703
|
-
|
|
704
|
-
| Hook | Trigger |
|
|
705
|
-
|------|---------|
|
|
706
|
-
| `onWindow(event, fn)` | Any `window` event |
|
|
707
|
-
| `onWindowScroll(fn)` | Window scroll |
|
|
708
|
-
| `onWindowResize(fn)` | Window resize |
|
|
709
|
-
| `onKeyDown(fn)` | Global `keydown` |
|
|
710
|
-
| `onKeyUp(fn)` | Global `keyup` |
|
|
711
|
-
| `onVisibilityChange(fn)` | Tab show/hide, receives `hidden: boolean` |
|
|
712
|
-
| `onClickAway(fn)` | Click outside host element |
|
|
713
|
-
| `onFileDrop(fn)` | Window file drop |
|
|
714
|
-
| `onScroll(fn, target?)` | Scroll on window or a specific element |
|
|
715
|
-
| `onResize(fn, target?)` | `ResizeObserver` on host element or a specific element |
|
|
716
|
-
| `onNavigate(fn)` | Path changes |
|
|
717
|
-
| `onInterval(fn, ms)` | Repeating timer |
|
|
718
|
-
| `onTimeout(fn, ms)` | One-shot timer |
|
|
719
|
-
| `onOnline(fn)` | Browser goes online |
|
|
720
|
-
| `onOffline(fn)` | Browser goes offline |
|
|
721
|
-
| `onStorageChange(fn)` | Cross-tab `localStorage` changes |
|
|
722
|
-
| `onPaste(fn)` | Clipboard paste |
|
|
723
|
-
| `onCopy(fn)` | Clipboard copy |
|
|
724
|
-
| `onCut(fn)` | Clipboard cut |
|
|
725
|
-
| `onSelectionChange(fn)` | Text selection changes |
|
|
726
|
-
| `onSocket(fn, sockets)` | WebSocket messages (see WebSocket section) |
|
|
727
|
-
| `onUpdate(fn)` | Every re-render of the component |
|
|
728
|
-
|
|
729
|
-
---
|
|
730
|
-
|
|
731
|
-
## Async Actions
|
|
732
|
-
|
|
733
|
-
### `asyncAction`
|
|
734
|
-
|
|
735
|
-
`asyncAction` wraps an async function and gives it a complete **loading / error / data state machine** with atoms, automatic cancellation of stale calls, caching, polling, and interceptors — all built in.
|
|
736
|
-
|
|
737
|
-
```typescript
|
|
738
|
-
import { asyncAction, html, x, renderX } from "mates";
|
|
739
|
-
import type { Props } from "mates";
|
|
740
|
-
|
|
741
|
-
const fetchUser = asyncAction(async (id: number) => {
|
|
742
|
-
const res = await fetch(`/api/users/${id}`);
|
|
743
|
-
if (!res.ok) throw new Error("Not found");
|
|
744
|
-
return res.json() as Promise<{ id: number; name: string; email: string }>;
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
const UserCard = (propsFn: Props<{ userId: number }>) => {
|
|
748
|
-
// Load data when the component mounts
|
|
749
|
-
onMount(() => {
|
|
750
|
-
fetchUser(propsFn().userId);
|
|
751
|
-
});
|
|
752
|
-
|
|
753
|
-
return () => html`
|
|
754
|
-
<div class="card">
|
|
755
|
-
${fetchUser.isLoading()
|
|
756
|
-
? html`<p>Loading…</p>`
|
|
757
|
-
: fetchUser.error()
|
|
758
|
-
? html`<p class="error">${fetchUser.error()!.message}</p>`
|
|
759
|
-
: fetchUser.data()
|
|
760
|
-
? html`
|
|
761
|
-
<h2>${fetchUser.data()!.name}</h2>
|
|
762
|
-
<p>${fetchUser.data()!.email}</p>
|
|
763
|
-
`
|
|
764
|
-
: html`<p>No user loaded</p>`}
|
|
765
|
-
<button @click=${() => fetchUser(propsFn().userId)}>Reload</button>
|
|
766
|
-
</div>
|
|
767
|
-
`;
|
|
768
|
-
};
|
|
769
|
-
```
|
|
770
|
-
|
|
771
|
-
**State atoms on every `asyncAction`:**
|
|
772
|
-
|
|
773
|
-
| Atom | Type | Description |
|
|
774
|
-
|------|------|-------------|
|
|
775
|
-
| `.data` | `AtomType<T \| null>` | The resolved value |
|
|
776
|
-
| `.error` | `AtomType<Error \| null>` | The rejection error |
|
|
777
|
-
| `.isLoading` | `AtomType<boolean>` | `true` while in flight |
|
|
778
|
-
| `.status` | `AtomType<"init" \| "loading" \| "success" \| "error">` | Full state machine status |
|
|
779
|
-
|
|
780
|
-
**Methods on every `asyncAction`:**
|
|
781
|
-
|
|
782
|
-
| Method | Description |
|
|
783
|
-
|--------|-------------|
|
|
784
|
-
| `.cancel()` | Abort the current in-flight call |
|
|
785
|
-
| `.cache(...args)` | Call and cache result by args (LRU, configurable size + TTL) |
|
|
786
|
-
| `.clearCache(...args)` | Evict specific cached entries |
|
|
787
|
-
| `.startPolling(...args)` | Begin polling the function on a fixed interval |
|
|
788
|
-
| `.stopPolling()` | Stop polling |
|
|
789
|
-
| `.subscribe(fn)` | Subscribe to completion events |
|
|
790
|
-
| `.interceptBefore(fn)` | Middleware — transform args before the function runs |
|
|
791
|
-
| `.interceptAfter(fn)` | Transform the resolved value before it hits `.data` |
|
|
792
|
-
|
|
793
|
-
**Options:**
|
|
794
|
-
|
|
795
|
-
```typescript
|
|
796
|
-
const fetchData = asyncAction(myFn, {
|
|
797
|
-
cacheLimit: 20, // Max LRU entries (default: 10)
|
|
798
|
-
cacheDuration: 60_000, // Cache TTL in ms (default: infinite)
|
|
799
|
-
pollInterval: 5_000, // Polling interval in ms (default: 5000)
|
|
800
|
-
});
|
|
801
|
-
```
|
|
802
|
-
|
|
803
|
-
**Stale-call cancellation is automatic.** If you call the action a second time before the first resolves, the first call is aborted and its result is discarded. Only the latest call's data is ever written to `.data`.
|
|
804
|
-
|
|
805
|
-
---
|
|
806
|
-
|
|
807
|
-
### `action` — synchronous action
|
|
808
|
-
|
|
809
|
-
`action` wraps a synchronous function with subscriber notifications, `interceptBefore`/`interceptAfter` middleware, and hot-swappable implementation.
|
|
810
|
-
|
|
811
|
-
```typescript
|
|
812
|
-
import { action } from "mates";
|
|
813
|
-
|
|
814
|
-
const addToCart = action((productId: string, quantity: number) => {
|
|
815
|
-
cart.update((c) => { c[productId] = (c[productId] ?? 0) + quantity; });
|
|
816
|
-
return { productId, quantity };
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
// Subscribe to every call
|
|
820
|
-
addToCart.__subscribe((result) => {
|
|
821
|
-
console.log("Added:", result);
|
|
822
|
-
analytics.track("add_to_cart", result);
|
|
823
|
-
});
|
|
824
|
-
|
|
825
|
-
// Add middleware
|
|
826
|
-
addToCart.interceptBefore((next, productId, quantity) => {
|
|
827
|
-
if (quantity < 1) throw new Error("Quantity must be positive");
|
|
828
|
-
return next(productId, quantity);
|
|
829
|
-
});
|
|
830
|
-
|
|
831
|
-
addToCart("prod_123", 2);
|
|
832
|
-
```
|
|
833
|
-
|
|
834
|
-
---
|
|
835
|
-
|
|
836
|
-
### `paginatedAsyncAction`
|
|
837
|
-
|
|
838
|
-
`paginatedAsyncAction` extends `asyncAction` with a built-in `page` atom and a `next()` helper.
|
|
839
|
-
|
|
840
|
-
```typescript
|
|
841
|
-
import { paginatedAsyncAction } from "mates";
|
|
842
|
-
|
|
843
|
-
const fetchPosts = paginatedAsyncAction(async () => {
|
|
844
|
-
const page = fetchPosts.page();
|
|
845
|
-
const res = await fetch(`/api/posts?page=${page}&limit=10`);
|
|
846
|
-
return res.json() as Promise<Post[]>;
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
// Load first page
|
|
850
|
-
fetchPosts();
|
|
851
|
-
|
|
852
|
-
// Load next page
|
|
853
|
-
fetchPosts.next();
|
|
854
|
-
|
|
855
|
-
// Jump to page
|
|
856
|
-
fetchPosts.page.set(3);
|
|
857
|
-
fetchPosts();
|
|
858
|
-
```
|
|
859
|
-
|
|
860
|
-
Extra atoms: `fetchPosts.page` (`AtomType<number>`), `fetchPosts.totalPages` (`AtomType<number>`).
|
|
861
|
-
|
|
862
|
-
---
|
|
863
|
-
|
|
864
|
-
### `taskAction`
|
|
865
|
-
|
|
866
|
-
`taskAction` queues async tasks and runs them one at a time, tracking the running status of each individual task.
|
|
867
|
-
|
|
868
|
-
```typescript
|
|
869
|
-
import { taskAction } from "mates";
|
|
870
|
-
|
|
871
|
-
const processFile = taskAction(async (file: File) => {
|
|
872
|
-
const result = await uploadFile(file);
|
|
873
|
-
return result.url;
|
|
874
|
-
});
|
|
875
|
-
|
|
876
|
-
// Queue multiple files — they run serially
|
|
877
|
-
processFile(file1);
|
|
878
|
-
processFile(file2);
|
|
879
|
-
processFile(file3);
|
|
880
|
-
|
|
881
|
-
// Check overall status
|
|
882
|
-
processFile.status(); // "loading" | "success" | "error" | "init"
|
|
883
|
-
processFile.data(); // result of the last completed task
|
|
884
|
-
```
|
|
885
|
-
|
|
886
|
-
---
|
|
887
|
-
|
|
888
|
-
## HTTP Client
|
|
889
|
-
|
|
890
|
-
Mates includes a first-party HTTP client with interceptors, URL template substitution, automatic JSON serialization, SSR support, and `asyncAction` integration.
|
|
891
|
-
|
|
892
|
-
### Basic usage
|
|
893
|
-
|
|
894
|
-
```typescript
|
|
895
|
-
import { Fetch, Get, Post, Put, Patch, Delete } from "mates";
|
|
896
|
-
|
|
897
|
-
// GET with query params
|
|
898
|
-
const users = await Get({ url: "/api/users", params: { page: 1, limit: 10 } });
|
|
899
|
-
|
|
900
|
-
// POST with a JSON body
|
|
901
|
-
const created = await Post({
|
|
902
|
-
url: "/api/users",
|
|
903
|
-
body: { name: "Alice", email: "alice@example.com" },
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
// URL template substitution — :id is replaced, extra params become query string
|
|
907
|
-
const user = await Get({ url: "/api/users/:id", params: { id: 42, include: "posts" } });
|
|
908
|
-
// → GET /api/users/42?include=posts
|
|
909
|
-
|
|
910
|
-
// Shorthand — pass just a URL string
|
|
911
|
-
const data = await Fetch("/api/health");
|
|
912
|
-
```
|
|
913
|
-
|
|
914
|
-
### FetchClient — per-instance configuration
|
|
915
|
-
|
|
916
|
-
Create a dedicated client for each API with a shared base config and per-instance interceptors:
|
|
917
|
-
|
|
918
|
-
```typescript
|
|
919
|
-
import { FetchClient } from "mates";
|
|
920
|
-
|
|
921
|
-
const api = new FetchClient({
|
|
922
|
-
host: "https://api.example.com",
|
|
923
|
-
headers: { "Accept": "application/json" },
|
|
924
|
-
});
|
|
925
|
-
|
|
926
|
-
// Add auth to every request
|
|
927
|
-
api.interceptBefore((url, opts) => ({
|
|
928
|
-
url,
|
|
929
|
-
options: {
|
|
930
|
-
...opts,
|
|
931
|
-
headers: { ...opts.headers, Authorization: `Bearer ${getToken()}` },
|
|
932
|
-
},
|
|
933
|
-
}));
|
|
934
|
-
|
|
935
|
-
// Log every error
|
|
936
|
-
api.interceptError((error) => {
|
|
937
|
-
logger.error(error);
|
|
938
|
-
});
|
|
939
|
-
|
|
940
|
-
const users = await api.Get({ url: "/users" });
|
|
941
|
-
const me = await api.Get({ url: "/users/me" });
|
|
942
|
-
```
|
|
943
|
-
|
|
944
|
-
### `fetchAction` / HTTP action shortcuts
|
|
945
|
-
|
|
946
|
-
Combine the HTTP client with `asyncAction` in one line:
|
|
947
|
-
|
|
948
|
-
```typescript
|
|
949
|
-
import { getAction, postAction, deleteAction } from "mates";
|
|
950
|
-
|
|
951
|
-
// getAction creates an asyncAction that calls GET
|
|
952
|
-
const loadUsers = getAction({ url: "/api/users" });
|
|
953
|
-
loadUsers();
|
|
954
|
-
|
|
955
|
-
// postAction with dynamic body
|
|
956
|
-
const createUser = postAction<User>();
|
|
957
|
-
createUser.interceptBefore((next) => next({ url: "/api/users", body: form() }));
|
|
958
|
-
createUser();
|
|
959
|
-
|
|
960
|
-
// Per-client action
|
|
961
|
-
const api = new FetchClient({ host: "https://api.example.com" });
|
|
962
|
-
const loadProfile = api.getAction({ url: "/users/me" });
|
|
963
|
-
loadProfile();
|
|
964
|
-
```
|
|
965
|
-
|
|
966
|
-
---
|
|
967
|
-
|
|
968
|
-
## Routing
|
|
969
|
-
|
|
970
|
-
### Navigation
|
|
971
|
-
|
|
972
|
-
```typescript
|
|
973
|
-
import { navigateTo, pathAtom, qsAtom, hashAtom, location } from "mates";
|
|
974
|
-
|
|
975
|
-
// Push a new history entry
|
|
976
|
-
navigateTo("/about");
|
|
977
|
-
|
|
978
|
-
// Replace current history entry (no back button entry)
|
|
979
|
-
navigateTo("/login", true);
|
|
980
|
-
|
|
981
|
-
// With history state data
|
|
982
|
-
navigateTo("/checkout", false, { step: 2 });
|
|
983
|
-
|
|
984
|
-
// Read current location reactively
|
|
985
|
-
pathAtom(); // "/about"
|
|
986
|
-
qsAtom(); // { q: "search term" } — parsed query string
|
|
987
|
-
hashAtom(); // "#section-2"
|
|
988
|
-
location.pathname; // "/about"
|
|
989
|
-
|
|
990
|
-
// Update query string (replaces history state)
|
|
991
|
-
qsAtom.set({ q: "mates framework", page: 2 });
|
|
992
|
-
|
|
993
|
-
// Navigation lock — prevents navigation (e.g. unsaved changes)
|
|
994
|
-
lockNavigation();
|
|
995
|
-
unlockNavigation();
|
|
996
|
-
navigationLocked(); // true/false — reactive
|
|
997
|
-
```
|
|
998
|
-
|
|
999
|
-
### `Router` — declarative route table
|
|
1000
|
-
|
|
1001
|
-
```typescript
|
|
1002
|
-
import { Router, navigateTo, html, renderX } from "mates";
|
|
1003
|
-
|
|
1004
|
-
// Define components
|
|
1005
|
-
const HomePage = () => () => html`<h1>Home</h1>`;
|
|
1006
|
-
const AboutPage = () => () => html`<h1>About</h1>`;
|
|
1007
|
-
const UserPage = (p: Props<{}>) => () => html`<h1>User</h1>`;
|
|
1008
|
-
const NotFound = () => () => html`<h1>404 — Not Found</h1>`;
|
|
1009
|
-
|
|
1010
|
-
// Create the router
|
|
1011
|
-
const appRouter = Router([
|
|
1012
|
-
{ path: "/", component: HomePage },
|
|
1013
|
-
{ path: "/about", component: AboutPage },
|
|
1014
|
-
{ path: "/users/:id", component: UserPage },
|
|
1015
|
-
// Lazy-loaded route — code-split automatically
|
|
1016
|
-
{ path: "/dashboard", component: async () => import("./Dashboard") },
|
|
1017
|
-
], NotFound);
|
|
1018
|
-
|
|
1019
|
-
// Mount
|
|
1020
|
-
const App = () => () => html`
|
|
1021
|
-
<nav>
|
|
1022
|
-
<a @click=${() => navigateTo("/")}>Home</a>
|
|
1023
|
-
<a @click=${() => navigateTo("/about")}>About</a>
|
|
1024
|
-
</nav>
|
|
1025
|
-
<main>${appRouter()}</main>
|
|
1026
|
-
`;
|
|
1027
|
-
|
|
1028
|
-
renderX(App, document.getElementById("app")!);
|
|
1029
|
-
```
|
|
1030
|
-
|
|
1031
|
-
### `route` — inline conditional routing
|
|
1032
|
-
|
|
1033
|
-
For simpler scenarios, use `route` directly in a template:
|
|
1034
|
-
|
|
1035
|
-
```typescript
|
|
1036
|
-
import { route, navigateTo, html } from "mates";
|
|
1037
|
-
|
|
1038
|
-
const App = () => () => html`
|
|
1039
|
-
${route("/", { view: HomePage })}
|
|
1040
|
-
${route("/about", { view: AboutPage })}
|
|
1041
|
-
${route("/users/:id", { view: UserPage })}
|
|
1042
|
-
`;
|
|
1043
|
-
```
|
|
1044
|
-
|
|
1045
|
-
### `animatedRouter` — page transitions
|
|
1046
|
-
|
|
1047
|
-
```typescript
|
|
1048
|
-
import { animatedRouter, fadeInPreset, fadeOutPreset } from "mates";
|
|
1049
|
-
|
|
1050
|
-
const router = animatedRouter(routes, {
|
|
1051
|
-
enter: fadeInPreset,
|
|
1052
|
-
exit: fadeOutPreset,
|
|
1053
|
-
scrollToTop: true,
|
|
1054
|
-
});
|
|
1055
|
-
```
|
|
1056
|
-
|
|
1057
|
-
### `isPathMatching` — pattern testing
|
|
1058
|
-
|
|
1059
|
-
```typescript
|
|
1060
|
-
import { isPathMatching } from "mates";
|
|
1061
|
-
|
|
1062
|
-
isPathMatching("/"); // true when path is exactly "/"
|
|
1063
|
-
isPathMatching("/users/:id"); // true for "/users/42", "/users/abc", etc.
|
|
1064
|
-
isPathMatching("/posts/:id"); // false when on "/users/42"
|
|
1065
|
-
```
|
|
1066
|
-
|
|
1067
|
-
### `buildPath` — fill URL templates
|
|
1068
|
-
|
|
1069
|
-
```typescript
|
|
1070
|
-
import { buildPath } from "mates";
|
|
1071
|
-
|
|
1072
|
-
buildPath("/users/:id/posts/:postId", { id: 42, postId: 7, highlight: true });
|
|
1073
|
-
// → "/users/42/posts/7?highlight=true"
|
|
1074
|
-
```
|
|
1075
|
-
|
|
1076
|
-
---
|
|
1077
|
-
|
|
1078
|
-
## CSS-in-JS & Theming
|
|
1079
|
-
|
|
1080
|
-
### Scoped stylesheets with `stylesheet`
|
|
1081
|
-
|
|
1082
|
-
`stylesheet()` creates a **scoped** CSS-in-JS instance. Each call generates unique class names so styles from different components never collide. Call it at module level, then call `mount()` inside the component.
|
|
1083
|
-
|
|
1084
|
-
```typescript
|
|
1085
|
-
import { stylesheet, html, renderX } from "mates";
|
|
1086
|
-
|
|
1087
|
-
// Module-level — created once
|
|
1088
|
-
const { css, mount, keyframes } = stylesheet();
|
|
1089
|
-
|
|
1090
|
-
// Define an animation
|
|
1091
|
-
const spin = keyframes("spin", {
|
|
1092
|
-
from: { transform: "rotate(0deg)" },
|
|
1093
|
-
to: { transform: "rotate(360deg)" },
|
|
1094
|
-
});
|
|
1095
|
-
|
|
1096
|
-
// Define styles
|
|
1097
|
-
const cl = css({
|
|
1098
|
-
card: {
|
|
1099
|
-
display: "flex",
|
|
1100
|
-
flexDirection: "column",
|
|
1101
|
-
padding: "1.5rem",
|
|
1102
|
-
borderRadius: "12px",
|
|
1103
|
-
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
|
1104
|
-
"&:hover": { boxShadow: "0 4px 16px rgba(0,0,0,0.15)" },
|
|
1105
|
-
"md": { padding: "2rem" }, // breakpoint shorthand
|
|
1106
|
-
},
|
|
1107
|
-
title: { fontSize: "1.25rem", fontWeight: "600" },
|
|
1108
|
-
loader: { animation: `${spin} 1s linear infinite` },
|
|
1109
|
-
});
|
|
1110
|
-
|
|
1111
|
-
const Card = () => {
|
|
1112
|
-
mount(); // injects styles when component mounts, removes on unmount
|
|
1113
|
-
|
|
1114
|
-
return () => html`
|
|
1115
|
-
<div class="${cl.card}">
|
|
1116
|
-
<h2 class="${cl.title}">Hello</h2>
|
|
1117
|
-
</div>
|
|
1118
|
-
`;
|
|
1119
|
-
};
|
|
1120
|
-
```
|
|
1121
|
-
|
|
1122
|
-
**Supported nested keys inside a block:**
|
|
1123
|
-
|
|
1124
|
-
| Key pattern | Compiled to |
|
|
1125
|
-
|-------------|-------------|
|
|
1126
|
-
| `"&:hover"` | `.block:hover { … }` |
|
|
1127
|
-
| `"&::before"` | `.block::before { … }` |
|
|
1128
|
-
| `"&[disabled]"` | `.block[disabled] { … }` |
|
|
1129
|
-
| `"sm"`, `"md"`, `"lg"`, `"xl"`, `"2xl"` | `@media (min-width: …) { … }` |
|
|
1130
|
-
| `"@media (…)"` | `@media (…) { … }` |
|
|
1131
|
-
|
|
1132
|
-
Configure custom breakpoints globally:
|
|
1133
|
-
|
|
1134
|
-
```typescript
|
|
1135
|
-
import { configureCSS } from "mates";
|
|
1136
|
-
|
|
1137
|
-
configureCSS({ breakpoints: { tablet: "900px", desktop: "1200px" } });
|
|
1138
|
-
```
|
|
1139
|
-
|
|
1140
|
-
### Global styles with `globalCSS`
|
|
1141
|
-
|
|
1142
|
-
```typescript
|
|
1143
|
-
import { globalCSS } from "mates";
|
|
1144
|
-
|
|
1145
|
-
// Singleton — injected once into <head>
|
|
1146
|
-
const g = globalCSS({
|
|
1147
|
-
body: { margin: "0", fontFamily: "'Inter', sans-serif" },
|
|
1148
|
-
"*, *::before, *::after": { boxSizing: "border-box" },
|
|
1149
|
-
a: { color: "inherit", textDecoration: "none" },
|
|
1150
|
-
});
|
|
1151
|
-
```
|
|
1152
|
-
|
|
1153
|
-
### Design tokens and theming with `globalTheme`
|
|
1154
|
-
|
|
1155
|
-
```typescript
|
|
1156
|
-
import { globalTheme } from "mates";
|
|
1157
|
-
|
|
1158
|
-
const { cssVars, themeAtom } = globalTheme({
|
|
1159
|
-
light: {
|
|
1160
|
-
primary: "#3b82f6",
|
|
1161
|
-
background: "#ffffff",
|
|
1162
|
-
surface: "#f8fafc",
|
|
1163
|
-
text: "#111827",
|
|
1164
|
-
border: "#e5e7eb",
|
|
1165
|
-
},
|
|
1166
|
-
dark: {
|
|
1167
|
-
primary: "#60a5fa",
|
|
1168
|
-
background: "#0f172a",
|
|
1169
|
-
surface: "#1e293b",
|
|
1170
|
-
text: "#f1f5f9",
|
|
1171
|
-
border: "#334155",
|
|
1172
|
-
},
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
// cssVars maps token names to CSS custom property strings
|
|
1176
|
-
// cssVars.primary → "--primary" cssVars.background → "--background"
|
|
1177
|
-
|
|
1178
|
-
// Use in stylesheet
|
|
1179
|
-
const cl = css({
|
|
1180
|
-
button: {
|
|
1181
|
-
backgroundColor: `var(${cssVars.primary})`,
|
|
1182
|
-
color: `var(${cssVars.background})`,
|
|
1183
|
-
},
|
|
1184
|
-
});
|
|
1185
|
-
|
|
1186
|
-
// Switch theme reactively
|
|
1187
|
-
themeAtom.set("dark"); // adds data-theme="dark" to <html>
|
|
1188
|
-
themeAtom.set("auto"); // uses OS preference via prefers-color-scheme
|
|
1189
|
-
themeAtom.set("light"); // explicit light
|
|
1190
|
-
```
|
|
1191
|
-
|
|
1192
|
-
`globalTheme` injects:
|
|
1193
|
-
- `:root { ... }` — first theme as base variables
|
|
1194
|
-
- `[data-theme="name"] { ... }` — each theme explicitly
|
|
1195
|
-
- `@media (prefers-color-scheme: dark) { :root:not([data-theme]) { ... } }` — OS auto mode
|
|
1196
|
-
|
|
1197
|
-
### `cl` helper — conditional class names
|
|
1198
|
-
|
|
1199
|
-
```typescript
|
|
1200
|
-
import { cl } from "mates";
|
|
1201
|
-
|
|
1202
|
-
// Merge conditional class names into a single string
|
|
1203
|
-
const classes = cl(
|
|
1204
|
-
styles.btn,
|
|
1205
|
-
isActive && styles.active,
|
|
1206
|
-
isLoading && styles.loading,
|
|
1207
|
-
);
|
|
1208
|
-
```
|
|
1209
|
-
|
|
1210
|
-
---
|
|
1211
|
-
|
|
1212
|
-
## Directives
|
|
1213
|
-
|
|
1214
|
-
Directives are attribute and child-position bindings for `lit-html` templates.
|
|
1215
|
-
|
|
1216
|
-
### DOM manipulation directives
|
|
1217
|
-
|
|
1218
|
-
```typescript
|
|
1219
|
-
import { attr, style, classes } from "mates";
|
|
1220
|
-
|
|
1221
|
-
html`
|
|
1222
|
-
<!-- Set / remove attributes declaratively -->
|
|
1223
|
-
<div ${attr({ id: "main", "aria-label": "content", disabled: isDisabled })}>
|
|
1224
|
-
|
|
1225
|
-
<!-- Apply inline styles -->
|
|
1226
|
-
<div ${style({ color: "red", display: isVisible ? "block" : "none" })}>
|
|
1227
|
-
|
|
1228
|
-
<!-- Conditional class management -->
|
|
1229
|
-
<button ${classes([
|
|
1230
|
-
"btn",
|
|
1231
|
-
[isPrimary, "btn-primary", "btn-secondary"], // ternary tuple
|
|
1232
|
-
[isLoading, "btn-loading"], // conditional tuple
|
|
1233
|
-
isDisabled && "btn-disabled", // falsy-safe expression
|
|
1234
|
-
])}>
|
|
1235
|
-
`
|
|
1236
|
-
```
|
|
1237
|
-
|
|
1238
|
-
### Lifecycle directives
|
|
1239
|
-
|
|
1240
|
-
```typescript
|
|
1241
|
-
import { onConnect, onDisconnect, onUpdate, onIntersect, onVisible, lazyLoad } from "mates";
|
|
1242
|
-
|
|
1243
|
-
html`
|
|
1244
|
-
<!-- React to element entering/leaving the DOM -->
|
|
1245
|
-
<div ${onConnect((el) => console.log("connected", el))}>
|
|
1246
|
-
<div ${onDisconnect(() => cleanup())}>
|
|
1247
|
-
|
|
1248
|
-
<!-- Re-run on every re-render of this node -->
|
|
1249
|
-
<canvas ${onUpdate((el) => drawChart(el))}>
|
|
1250
|
-
|
|
1251
|
-
<!-- IntersectionObserver -->
|
|
1252
|
-
<section ${onIntersect({
|
|
1253
|
-
onVisible: (el) => el.classList.add("visible"),
|
|
1254
|
-
onHidden: (el) => el.classList.remove("visible"),
|
|
1255
|
-
rootMargin: "0px 0px -100px 0px",
|
|
1256
|
-
})}>
|
|
1257
|
-
|
|
1258
|
-
<!-- Lazy load when scrolled into view -->
|
|
1259
|
-
<img ${lazyLoad((el) => {
|
|
1260
|
-
(el as HTMLImageElement).src = el.dataset.src!;
|
|
1261
|
-
})} data-src="/images/photo.jpg" />
|
|
1262
|
-
`
|
|
1263
|
-
```
|
|
1264
|
-
|
|
1265
|
-
### Rendering helpers
|
|
1266
|
-
|
|
1267
|
-
```typescript
|
|
1268
|
-
import { renderSwitch, animatedIf } from "mates";
|
|
1269
|
-
|
|
1270
|
-
// Render first matching case
|
|
1271
|
-
html`${renderSwitch([
|
|
1272
|
-
[status() === "loading", html`<spinner-el></spinner-el>`],
|
|
1273
|
-
[status() === "error", html`<error-msg .msg=${error()}></error-msg>`],
|
|
1274
|
-
[status() === "success", html`<user-card .user=${data()}></user-card>`],
|
|
1275
|
-
html`<p>Idle</p>`, // default fallback
|
|
1276
|
-
])}`
|
|
1277
|
-
|
|
1278
|
-
// Conditional rendering with animated transitions
|
|
1279
|
-
html`${animatedIf(
|
|
1280
|
-
isOpen(),
|
|
1281
|
-
() => html`<div class="panel">...</div>`,
|
|
1282
|
-
undefined,
|
|
1283
|
-
{ enter: fadeInPreset, exit: fadeOutPreset },
|
|
1284
|
-
)}`
|
|
1285
|
-
```
|
|
1286
|
-
|
|
1287
|
-
### Event directives
|
|
1288
|
-
|
|
1289
|
-
```typescript
|
|
1290
|
-
import { on } from "mates";
|
|
1291
|
-
|
|
1292
|
-
html`
|
|
1293
|
-
<!-- Declarative event map — cleaned up automatically -->
|
|
1294
|
-
<div ${on({ click: handleClick, mouseover: handleHover })}>
|
|
1295
|
-
`
|
|
1296
|
-
```
|
|
1297
|
-
|
|
1298
|
-
### `eleHook` / `htmlHook` — custom directives
|
|
1299
|
-
|
|
1300
|
-
Build your own reusable directives using the low-level hooks:
|
|
1301
|
-
|
|
1302
|
-
```typescript
|
|
1303
|
-
import { eleHook, htmlHook } from "mates";
|
|
1304
|
-
|
|
1305
|
-
// eleHook — bound to a real element
|
|
1306
|
-
const autoFocus = eleHook(($) => {
|
|
1307
|
-
($.el as HTMLElement).focus();
|
|
1308
|
-
|
|
1309
|
-
return {
|
|
1310
|
-
onCleanup() { /* cleanup */ },
|
|
1311
|
-
};
|
|
1312
|
-
});
|
|
1313
|
-
|
|
1314
|
-
html`<input ${autoFocus()} />`
|
|
1315
|
-
|
|
1316
|
-
// htmlHook — for inline content slots (no element required)
|
|
1317
|
-
const liveTime = htmlHook((render) => {
|
|
1318
|
-
const tick = () => render(html`<span>${new Date().toLocaleTimeString()}</span>`);
|
|
1319
|
-
tick();
|
|
1320
|
-
const id = setInterval(tick, 1000);
|
|
1321
|
-
return { onCleanup: () => clearInterval(id) };
|
|
1322
|
-
});
|
|
1323
|
-
|
|
1324
|
-
html`<p>Current time: ${liveTime()}</p>`
|
|
1325
|
-
```
|
|
1326
|
-
|
|
1327
|
-
### `$` — fluent DOM chain
|
|
1328
|
-
|
|
1329
|
-
Imperatively manipulate elements inside hooks and lifecycle callbacks:
|
|
1330
|
-
|
|
1331
|
-
```typescript
|
|
1332
|
-
import { $ } from "mates";
|
|
1333
|
-
|
|
1334
|
-
$(el)
|
|
1335
|
-
.attr({ "data-active": "true", "aria-expanded": "false" })
|
|
1336
|
-
.style({ color: "red", transform: `translateX(${offset}px)` })
|
|
1337
|
-
.classes(["base", [isActive, "active"], [isError, "error", "ok"]])
|
|
1338
|
-
.on("click", handleClick)
|
|
1339
|
-
.focus()
|
|
1340
|
-
.scroll({ top: 0, behavior: "smooth" });
|
|
1341
|
-
```
|
|
1342
|
-
|
|
1343
|
-
---
|
|
1344
|
-
|
|
1345
|
-
## Portals, Dialogs & Tooltips
|
|
1346
|
-
|
|
1347
|
-
Render content **outside** the normal DOM tree — useful for modals, toasts, context menus, and tooltips that must escape `overflow: hidden` or `transform` stacking contexts.
|
|
1348
|
-
|
|
1349
|
-
### `portal` — fixed-position overlay
|
|
1350
|
-
|
|
1351
|
-
```typescript
|
|
1352
|
-
import { portal, html } from "mates";
|
|
1353
|
-
|
|
1354
|
-
html`
|
|
1355
|
-
${isToastVisible() && portal(
|
|
1356
|
-
html`<div class="toast">Saved ✓</div>`,
|
|
1357
|
-
{ style: { bottom: "16px", right: "16px", position: "fixed" } },
|
|
1358
|
-
)}
|
|
1359
|
-
`
|
|
1360
|
-
```
|
|
1361
|
-
|
|
1362
|
-
### `dialog` — modal with backdrop
|
|
1363
|
-
|
|
1364
|
-
```typescript
|
|
1365
|
-
import { dialog, html } from "mates";
|
|
1366
|
-
|
|
1367
|
-
html`
|
|
1368
|
-
${isOpen() && dialog(
|
|
1369
|
-
html`
|
|
1370
|
-
<div class="modal">
|
|
1371
|
-
<h2>Confirm Delete</h2>
|
|
1372
|
-
<p>This action cannot be undone.</p>
|
|
1373
|
-
<button @click=${() => isOpen.set(false)}>Cancel</button>
|
|
1374
|
-
<button @click=${doDelete}>Delete</button>
|
|
1375
|
-
</div>
|
|
1376
|
-
`,
|
|
1377
|
-
{
|
|
1378
|
-
onBackdropClick: () => isOpen.set(false),
|
|
1379
|
-
style: { backdropColor: "rgba(0,0,0,0.6)" },
|
|
1380
|
-
dialogStyle: { borderRadius: "16px", padding: "2rem", maxWidth: "480px" },
|
|
1381
|
-
},
|
|
1382
|
-
)}
|
|
1383
|
-
`
|
|
1384
|
-
```
|
|
1385
|
-
|
|
1386
|
-
`dialog` automatically prevents body scroll while open and restores it exactly on close, even with nested dialogs.
|
|
1387
|
-
|
|
1388
|
-
### `tooltip` / `tip`
|
|
1389
|
-
|
|
1390
|
-
```typescript
|
|
1391
|
-
import { tooltip, html } from "mates";
|
|
1392
|
-
|
|
1393
|
-
html`
|
|
1394
|
-
<!-- Plain string tip -->
|
|
1395
|
-
<button ${tooltip("Save changes (Ctrl+S)")}>Save</button>
|
|
1396
|
-
|
|
1397
|
-
<!-- Rich HTML tip -->
|
|
1398
|
-
<button ${tooltip(html`Press <kbd>Ctrl</kbd> + <kbd>S</kbd>`)}>Save</button>
|
|
1399
|
-
|
|
1400
|
-
<!-- Custom style -->
|
|
1401
|
-
<span ${tooltip("Long description", { maxWidth: "280px", placement: "bottom" })}>
|
|
1402
|
-
Hover me
|
|
1403
|
-
</span>
|
|
1404
|
-
`
|
|
1405
|
-
```
|
|
1406
|
-
|
|
1407
|
-
Tooltips auto-position above/below based on available viewport space. They use a singleton overlay element on `<body>` — no portal proliferation.
|
|
1408
|
-
|
|
1409
|
-
---
|
|
1410
|
-
|
|
1411
|
-
## Animations
|
|
1412
|
-
|
|
1413
|
-
Mates provides a first-party animation system built on the Web Animations API (WAAPI).
|
|
1414
|
-
|
|
1415
|
-
```typescript
|
|
1416
|
-
import {
|
|
1417
|
-
animate,
|
|
1418
|
-
animateDirective,
|
|
1419
|
-
fadeInPreset,
|
|
1420
|
-
fadeOutPreset,
|
|
1421
|
-
slideInPreset,
|
|
1422
|
-
slideOutPreset,
|
|
1423
|
-
scaleInPreset,
|
|
1424
|
-
bouncePreset,
|
|
1425
|
-
springInPreset,
|
|
1426
|
-
withStaggerPreset,
|
|
1427
|
-
} from "mates";
|
|
1428
|
-
|
|
1429
|
-
// Imperative — animate an element directly
|
|
1430
|
-
animate(el, fadeInPreset);
|
|
1431
|
-
animate(el, { keyframes: [{ opacity: 0 }, { opacity: 1 }], duration: 300 });
|
|
1432
|
-
|
|
1433
|
-
// Directive — declaratively apply to a template element
|
|
1434
|
-
html`
|
|
1435
|
-
<div ${animateDirective({ enter: fadeInPreset, exit: fadeOutPreset })}>
|
|
1436
|
-
Content
|
|
1437
|
-
</div>
|
|
1438
|
-
`
|
|
1439
|
-
|
|
1440
|
-
// Stagger children
|
|
1441
|
-
html`
|
|
1442
|
-
<ul ${animateDirective(withStaggerPreset(fadeInPreset, { stagger: 50 }))}>
|
|
1443
|
-
${items.map((i) => html`<li>${i}</li>`)}
|
|
1444
|
-
</ul>
|
|
1445
|
-
`
|
|
1446
|
-
```
|
|
1447
|
-
|
|
1448
|
-
**Built-in animation presets:**
|
|
1449
|
-
|
|
1450
|
-
| Preset | Effect |
|
|
1451
|
-
|--------|--------|
|
|
1452
|
-
| `fadeInPreset` / `fadeOutPreset` | Opacity fade |
|
|
1453
|
-
| `slideInPreset` / `slideOutPreset` | Slide from edge |
|
|
1454
|
-
| `scaleInPreset` / `scaleOutPreset` | Scale from center |
|
|
1455
|
-
| `blurInPreset` / `blurOutPreset` | Blur + fade |
|
|
1456
|
-
| `flipInPreset` / `flipOutPreset` | 3D flip |
|
|
1457
|
-
| `bouncePreset` | Elastic bounce |
|
|
1458
|
-
| `pulsePreset` | Heartbeat pulse |
|
|
1459
|
-
| `shakePreset` | Horizontal shake |
|
|
1460
|
-
| `spinPreset` | Full rotation |
|
|
1461
|
-
| `springInPreset` | Spring physics enter |
|
|
1462
|
-
| `withStaggerPreset` | Wrap any preset with stagger |
|
|
1463
|
-
|
|
1464
|
-
---
|
|
1465
|
-
|
|
1466
|
-
## WebSocket
|
|
1467
|
-
|
|
1468
|
-
```typescript
|
|
1469
|
-
import { ws, onSocket, html } from "mates";
|
|
1470
|
-
|
|
1471
|
-
type ChatMessage = { user: string; text: string; ts: number };
|
|
1472
|
-
|
|
1473
|
-
const Chat = () => {
|
|
1474
|
-
const messages = atom<ChatMessage[]>([]);
|
|
1475
|
-
const input = atom("");
|
|
1476
|
-
|
|
1477
|
-
// Create connection — auto-reconnects, auto-cleanup on unmount
|
|
1478
|
-
const socket = ws<ChatMessage>("wss://api.example.com/chat", {
|
|
1479
|
-
reconnect: true,
|
|
1480
|
-
reconnectDelay: 1_000,
|
|
1481
|
-
reconnectMaxDelay: 30_000,
|
|
1482
|
-
auth: () => ({ token: authToken() }), // refreshed on every reconnect
|
|
1483
|
-
});
|
|
1484
|
-
|
|
1485
|
-
// Subscribe to incoming messages
|
|
1486
|
-
onSocket((msg) => {
|
|
1487
|
-
messages.update((list) => { list.push(msg); });
|
|
1488
|
-
}, [socket]);
|
|
1489
|
-
|
|
1490
|
-
const send = () => {
|
|
1491
|
-
socket.send({ user: "me", text: input(), ts: Date.now() });
|
|
1492
|
-
input.set("");
|
|
1493
|
-
};
|
|
1494
|
-
|
|
1495
|
-
return () => html`
|
|
1496
|
-
<div class="chat">
|
|
1497
|
-
<p>Status: ${socket.status()}</p>
|
|
1498
|
-
<ul>
|
|
1499
|
-
${messages().map((m) => html`<li><b>${m.user}:</b> ${m.text}</li>`)}
|
|
1500
|
-
</ul>
|
|
1501
|
-
<input .value=${input()} @input=${(e: InputEvent) =>
|
|
1502
|
-
input.set((e.target as HTMLInputElement).value)} />
|
|
1503
|
-
<button @click=${send}>Send</button>
|
|
1504
|
-
</div>
|
|
1505
|
-
`;
|
|
1506
|
-
};
|
|
1507
|
-
```
|
|
1508
|
-
|
|
1509
|
-
| Option | Default | Description |
|
|
1510
|
-
|--------|---------|-------------|
|
|
1511
|
-
| `reconnect` | `true` | Auto-reconnect on close/error |
|
|
1512
|
-
| `reconnectDelay` | `1000` | Initial delay in ms (doubles each attempt) |
|
|
1513
|
-
| `reconnectMaxDelay` | `30000` | Exponential backoff cap |
|
|
1514
|
-
| `reconnectMaxAttempts` | `Infinity` | Max attempts before giving up |
|
|
1515
|
-
| `auth` | — | `() => Record<string, string>` — query params added on connect |
|
|
1516
|
-
| `autoConnect` | `true` | Set to `false` to defer until `.connect()` is called manually |
|
|
1517
|
-
|
|
1518
|
-
---
|
|
1519
|
-
|
|
1520
|
-
## Virtualization
|
|
1521
|
-
|
|
1522
|
-
Render large lists and grids efficiently by only mounting visible items:
|
|
1523
|
-
|
|
1524
|
-
```typescript
|
|
1525
|
-
import { virtualList, virtualGrid, virtualMasonry, masonryGrid } from "mates";
|
|
1526
|
-
|
|
1527
|
-
// Virtualized list — only renders visible rows
|
|
1528
|
-
html`${virtualList({
|
|
1529
|
-
items: bigArray,
|
|
1530
|
-
itemHeight: 60,
|
|
1531
|
-
renderItem: (item, index) => html`
|
|
1532
|
-
<div class="row">${index}: ${item.name}</div>
|
|
1533
|
-
`,
|
|
1534
|
-
})}`
|
|
1535
|
-
|
|
1536
|
-
// Virtualized grid — only renders visible cells
|
|
1537
|
-
html`${virtualGrid({
|
|
1538
|
-
items: products,
|
|
1539
|
-
columns: 4,
|
|
1540
|
-
rowHeight: 240,
|
|
1541
|
-
renderItem: (product) => html`
|
|
1542
|
-
<div class="product-card">
|
|
1543
|
-
<img src=${product.image} />
|
|
1544
|
-
<p>${product.name}</p>
|
|
1545
|
-
</div>
|
|
1546
|
-
`,
|
|
1547
|
-
})}`
|
|
1548
|
-
|
|
1549
|
-
// Masonry layout (non-virtualized)
|
|
1550
|
-
html`${masonryGrid({
|
|
1551
|
-
items: photos,
|
|
1552
|
-
columns: 3,
|
|
1553
|
-
gap: 16,
|
|
1554
|
-
renderItem: (photo) => html`<img src=${photo.url} />`,
|
|
1555
|
-
})}`
|
|
1556
|
-
```
|
|
1557
|
-
|
|
1558
|
-
---
|
|
1559
|
-
|
|
1560
|
-
## DevTools
|
|
1561
|
-
|
|
1562
|
-
Mates DevTools is a companion browser extension. When installed, it provides component tree inspection, atom state history, time-travel debugging, and re-render tracking — all without any code changes.
|
|
1563
|
-
|
|
1564
|
-
To wire up custom DevTools integration at runtime:
|
|
1565
|
-
|
|
1566
|
-
```typescript
|
|
1567
|
-
import { installDevToolsHooks, isDevToolsInstalled } from "mates";
|
|
1568
|
-
|
|
1569
|
-
if (!isDevToolsInstalled()) {
|
|
1570
|
-
installDevToolsHooks({
|
|
1571
|
-
onAtomCreate: (atom) => { /* ... */ },
|
|
1572
|
-
onAtomSet: (atom, prev, next) => { /* ... */ },
|
|
1573
|
-
onRender: (component) => { /* ... */ },
|
|
1574
|
-
});
|
|
1575
|
-
}
|
|
1576
|
-
```
|
|
1577
|
-
|
|
1578
|
-
---
|
|
1579
|
-
|
|
1580
|
-
## TypeScript
|
|
1581
|
-
|
|
1582
|
-
All APIs are fully typed. The most common types you'll import:
|
|
1583
|
-
|
|
1584
|
-
```typescript
|
|
1585
|
-
import type {
|
|
1586
|
-
Props, // propsFn type: () => T
|
|
1587
|
-
Component, // outer fn → inner fn (closure component)
|
|
1588
|
-
TemplateFn, // fn → TemplateResult
|
|
1589
|
-
AtomType, // atom return type: AtomType<T>
|
|
1590
|
-
IAtomType, // iAtom return type
|
|
1591
|
-
AsyncActionReturnType, // asyncAction return type
|
|
1592
|
-
ActionReturnType, // action return type
|
|
1593
|
-
CSSBlock, // css-in-js block type
|
|
1594
|
-
CSSRulesInput, // full css() rules object type
|
|
1595
|
-
WsConfig, // WebSocket config
|
|
1596
|
-
WsConnection, // WebSocket connection handle
|
|
1597
|
-
} from "mates";
|
|
1598
|
-
```
|
|
1599
|
-
|
|
1600
|
-
**Typed component example:**
|
|
1601
|
-
|
|
1602
|
-
```typescript
|
|
1603
|
-
import type { Props, Component } from "mates";
|
|
1604
|
-
|
|
1605
|
-
interface ButtonProps {
|
|
1606
|
-
label: string;
|
|
1607
|
-
onClick: () => void;
|
|
1608
|
-
variant?: "primary" | "ghost" | "danger";
|
|
1609
|
-
disabled?: boolean;
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
const Button: Component<ButtonProps> = (propsFn) => {
|
|
1613
|
-
return () => {
|
|
1614
|
-
const { label, onClick, variant = "primary", disabled = false } = propsFn();
|
|
1615
|
-
return html`
|
|
1616
|
-
<button
|
|
1617
|
-
class="btn btn--${variant}"
|
|
1618
|
-
?disabled=${disabled}
|
|
1619
|
-
@click=${onClick}
|
|
1620
|
-
>${label}</button>
|
|
1621
|
-
`;
|
|
1622
|
-
};
|
|
1623
|
-
};
|
|
1624
|
-
```
|
|
1625
|
-
|
|
1626
|
-
---
|
|
1627
|
-
|
|
1628
|
-
## Features at a Glance
|
|
1629
|
-
|
|
1630
|
-
| Category | Feature |
|
|
1631
|
-
|----------|---------|
|
|
1632
|
-
| **Components** | Two-layer closure model, props as function, slot/children support |
|
|
1633
|
-
| **Reactivity** | `atom`, `iAtom`, `effect`, `memo`, `store`, reactive Map/Set |
|
|
1634
|
-
| **Local state** | `useState` with auto-async wrapping |
|
|
1635
|
-
| **Shared state** | Scope classes with `useScope` / `getParentScope` — no prop drilling |
|
|
1636
|
-
| **Persistence** | `lsAtom` (localStorage), `ssAtom` (sessionStorage), cross-tab sync |
|
|
1637
|
-
| **Async** | `asyncAction` with loading/error/data atoms, cancellation, polling, LRU cache |
|
|
1638
|
-
| **Pagination** | `paginatedAsyncAction` with built-in page atom and `next()` |
|
|
1639
|
-
| **Task queue** | `taskAction` for serial async work |
|
|
1640
|
-
| **HTTP** | `FetchClient` with interceptors, URL templates, JSON auto-serialization, SSR support |
|
|
1641
|
-
| **Routing** | `Router`, `route`, `navigateTo`, `pathAtom`, `qsAtom`, animated transitions |
|
|
1642
|
-
| **Navigation lock** | Built-in unsaved-changes guard |
|
|
1643
|
-
| **CSS-in-JS** | Scoped `stylesheet`, `globalCSS`, `keyframes` — zero runtime for static styles |
|
|
1644
|
-
| **Theming** | `globalTheme` with CSS custom properties, dark mode, OS auto-mode |
|
|
1645
|
-
| **Directives** | `attr`, `style`, `classes`, `on`, `onIntersect`, `lazyLoad`, `animatedIf`, etc. |
|
|
1646
|
-
| **Hooks** | `onMount`, `onPaint`, `onCleanup`, `onKeyDown`, `onInterval`, `onNavigate`, … |
|
|
1647
|
-
| **Portals** | `portal`, `dialog`, `tooltip` — escape DOM stacking contexts |
|
|
1648
|
-
| **Animations** | WAAPI wrapper, `animateDirective`, 15+ presets, stagger |
|
|
1649
|
-
| **WebSocket** | `ws()` with reconnect, exponential backoff, auth refresh, `onSocket` hook |
|
|
1650
|
-
| **Virtualization** | `virtualList`, `virtualGrid`, `virtualMasonry`, `masonryGrid` |
|
|
1651
|
-
| **DevTools** | Installable hook bridge for browser extension integration |
|
|
1652
|
-
| **SSR** | `isSSR`, `setSSRMode`, handler registry for zero-network server rendering |
|
|
1653
|
-
| **TypeScript** | Full type coverage, generics throughout, strict-mode safe |
|
|
1654
|
-
| **lit-html** | Full re-export of `html`, `svg`, `render`, `repeat`, `classMap`, `styleMap`, `when`, `cache`, `live`, `keyed`, `guard`, `until`, and more |
|
|
1655
|
-
|
|
1656
|
-
---
|
|
1657
|
-
|
|
1658
|
-
## How Mates Compares
|
|
1659
|
-
|
|
1660
|
-
### vs React
|
|
1661
|
-
|
|
1662
|
-
| | Mates | React |
|
|
1663
|
-
|---|---|---|
|
|
1664
|
-
| **Rendering** | `lit-html` patches the real DOM directly — no virtual DOM, no diffing tree | VDOM diff on every render, reconciler determines what to patch |
|
|
1665
|
-
| **Component model** | Two-layer closure — outer (setup) runs once, inner (render) runs on change | Function components re-run entirely on every render |
|
|
1666
|
-
| **Reactivity** | Fine-grained atoms — only components that read a changed atom re-run | Renders propagate top-down via props and context unless `memo`/`useMemo` are used |
|
|
1667
|
-
| **State** | `atom` (module or component-scoped), `useState`, `store` | `useState`, `useReducer`, `useContext`, external libraries |
|
|
1668
|
-
| **Side effects** | `effect(fn)` is reactive — re-runs when dependencies change | `useEffect` with manual dependency arrays |
|
|
1669
|
-
| **Computed values** | `memo(fn)` — auto-tracked dependencies | `useMemo(fn, deps)` — manual dependency arrays |
|
|
1670
|
-
| **Shared state** | Scopes (class-based, `useScope`/`getParentScope`) | Context API + `useContext` or external store (Zustand, Redux) |
|
|
1671
|
-
| **Compiler** | None — plain TypeScript, standard ESM | JSX transform required |
|
|
1672
|
-
| **Bundle size** | ~50 KB gzipped (framework + lit-html) | ~45 KB gzipped (React + ReactDOM) |
|
|
1673
|
-
| **HTTP** | Built-in `FetchClient` + `asyncAction` | Third-party (Axios, TanStack Query, SWR) |
|
|
1674
|
-
| **Routing** | Built-in `Router` | Third-party (React Router, TanStack Router) |
|
|
1675
|
-
| **CSS** | Built-in `stylesheet` + `globalTheme` | Third-party (Emotion, styled-components, CSS Modules) |
|
|
1676
|
-
| **Animation** | Built-in WAAPI presets + `animateDirective` | Third-party (Framer Motion, react-spring) |
|
|
1677
|
-
| **WebSocket** | Built-in `ws()` with reconnect | Third-party |
|
|
1678
|
-
| **Virtualization** | Built-in `virtualList`, `virtualGrid`, `virtualMasonry` | Third-party (react-window, TanStack Virtual) |
|
|
1679
|
-
|
|
1680
|
-
**Key difference:** In React, calling `setState` schedules a re-render of the whole component tree from that node down. In Mates, changing an atom only re-runs the specific inner functions and effects that read that atom — everything else stays untouched.
|
|
1681
|
-
|
|
1682
|
-
---
|
|
1683
|
-
|
|
1684
|
-
### vs Vue 3
|
|
1685
|
-
|
|
1686
|
-
| | Mates | Vue 3 |
|
|
1687
|
-
|---|---|---|
|
|
1688
|
-
| **Templates** | Tagged template literals — standard JavaScript, no compiler | `.vue` SFC files or JSX with Vite compiler |
|
|
1689
|
-
| **Reactivity** | `atom` — explicit, function-call reads | `ref`/`reactive` — Proxy-based, transparent reads |
|
|
1690
|
-
| **Components** | Plain closure functions | Options API or `setup()` function + `<template>` |
|
|
1691
|
-
| **Scoped styles** | `stylesheet()` — scoped class names | `<style scoped>` — attribute-based scoping |
|
|
1692
|
-
| **Router** | Built-in | Official but separate (`vue-router`) |
|
|
1693
|
-
| **State management** | `atom`, `store`, `scope` built-in | Official but separate (`pinia`) |
|
|
1694
|
-
| **Compiler** | None required | Required for SFC and template directives |
|
|
1695
|
-
|
|
1696
|
-
---
|
|
1697
|
-
|
|
1698
|
-
### vs Svelte
|
|
1699
|
-
|
|
1700
|
-
| | Mates | Svelte |
|
|
1701
|
-
|---|---|---|
|
|
1702
|
-
| **Build step** | None — TypeScript only | Required Svelte compiler |
|
|
1703
|
-
| **Reactivity** | Explicit `atom` calls | Compile-time `$:` labels / `$state` runes |
|
|
1704
|
-
| **Component files** | Plain `.ts` files | `.svelte` files with `<script>`, `<template>`, `<style>` sections |
|
|
1705
|
-
| **Bundle size** | Consistent ~50 KB | Per-component compiled output — small for tiny apps, grows with app size |
|
|
1706
|
-
| **TypeScript** | Native — no extra config | Requires `lang="ts"` + type-checking config |
|
|
1707
|
-
| **Animation** | Built-in WAAPI presets | Built-in `transition:`, `animate:` directives (compile-time) |
|
|
1708
|
-
|
|
1709
|
-
---
|
|
1710
|
-
|
|
1711
|
-
### vs SolidJS
|
|
1712
|
-
|
|
1713
|
-
| | Mates | SolidJS |
|
|
1714
|
-
|---|---|---|
|
|
1715
|
-
| **Rendering** | `lit-html` patches — no VDOM | Compiled fine-grained DOM updates |
|
|
1716
|
-
| **Reactivity** | `atom` — explicit function call to read | `createSignal`, `createEffect` — runs at compile time |
|
|
1717
|
-
| **Compiler** | None | JSX transform + reactivity transform required |
|
|
1718
|
-
| **Component re-runs** | Inner function re-runs on change | Components run once — JSX expressions are reactive subscriptions |
|
|
1719
|
-
| **Ecosystem** | Self-contained (router, HTTP, CSS, WS all built-in) | Growing ecosystem, mostly third-party |
|
|
1720
|
-
| **Learning curve** | Low — plain TypeScript + tagged templates | Moderate — must understand compiled reactive graph |
|
|
1721
|
-
|
|
1722
|
-
---
|
|
1723
|
-
|
|
1724
|
-
### vs Preact / Inferno
|
|
1725
|
-
|
|
1726
|
-
Both are drop-in React replacements with VDOM. Mates takes a fundamentally different approach (lit-html + fine-grained atoms) and ships a complete feature set out of the box rather than relying on the React ecosystem for routing, state, animations, and HTTP.
|
|
1727
|
-
|
|
1728
|
-
---
|
|
1729
|
-
|
|
1730
|
-
### Summary: When to choose Mates
|
|
1731
|
-
|
|
1732
|
-
✅ You want **no compiler magic** — just TypeScript and a build tool you already use
|
|
1733
|
-
✅ You want **everything in one package** — HTTP, routing, CSS, WS, animations, virtualization
|
|
1734
|
-
✅ You want **fine-grained reactivity** without a framework-specific compiler
|
|
1735
|
-
✅ You're building a **single-page application** with complex state and async flows
|
|
1736
|
-
✅ You want **predictable performance** — only what changed is ever re-rendered
|
|
1737
|
-
✅ You prefer **explicit over implicit** — reads and writes are always function calls
|
|
1738
|
-
|
|
1739
|
-
---
|
|
1740
120
|
|
|
1741
121
|
## IDE Support
|
|
1742
122
|
|