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