@thomas-siegfried/tapout 0.0.1 → 0.0.2
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/LICENSE +21 -21
- package/README.md +1205 -1205
- package/package.json +55 -55
- package/src/applyBindings.ts +372 -372
- package/src/arrayToDomMapping.ts +300 -300
- package/src/attributeInterpolationMarkup.ts +91 -91
- package/src/bindingContext.ts +207 -207
- package/src/bindingEvent.ts +198 -198
- package/src/bindingProvider.ts +260 -260
- package/src/bindings.ts +963 -963
- package/src/compareArrays.ts +122 -122
- package/src/componentBinding.ts +318 -318
- package/src/componentDecorator.ts +62 -62
- package/src/components.ts +367 -367
- package/src/computed.ts +439 -439
- package/src/configure.ts +52 -52
- package/src/controlFlowBindings.ts +206 -206
- package/src/core.ts +60 -60
- package/src/decorators.ts +407 -407
- package/src/dependencyDetection.ts +76 -76
- package/src/disposable.ts +36 -36
- package/src/domData.ts +42 -42
- package/src/domNodeDisposal.ts +94 -94
- package/src/effects.ts +29 -29
- package/src/event.ts +173 -173
- package/src/expressionRewriting.ts +219 -219
- package/src/extenders.ts +102 -102
- package/src/filters.ts +91 -91
- package/src/index.ts +150 -150
- package/src/interpolationMarkup.ts +130 -130
- package/src/memoization.ts +71 -71
- package/src/namespacedBindings.ts +132 -132
- package/src/observable.ts +48 -48
- package/src/observableArray.ts +397 -397
- package/src/options.ts +25 -25
- package/src/selectExtensions.ts +68 -68
- package/src/slotBinding.ts +84 -84
- package/src/subscribable.ts +266 -266
- package/src/tasks.ts +79 -79
- package/src/templateEngine.ts +108 -108
- package/src/templateRendering.ts +399 -399
- package/src/templateRewriting.ts +94 -94
- package/src/templateSources.ts +134 -134
- package/src/utils.ts +123 -123
- package/src/utilsDom.ts +87 -87
- package/src/virtualElements.ts +153 -153
- package/src/wireParams.ts +49 -49
package/README.md
CHANGED
|
@@ -1,1206 +1,1206 @@
|
|
|
1
|
-
# Tapout
|
|
2
|
-
|
|
3
|
-
A modern ESM reactivity and templating library, spiritually inspired by [KnockoutJS](https://knockoutjs.com/). Not a drop-in replacement — same philosophy, modern implementation.
|
|
4
|
-
|
|
5
|
-
Tapout provides dependency-tracked observables, computed values, declarative DOM bindings, a component system, and TC39 Stage 3 decorators — all in a lightweight, explicit architecture built on TypeScript.
|
|
6
|
-
|
|
7
|
-
## Table of Contents
|
|
8
|
-
|
|
9
|
-
- [Installation](#installation)
|
|
10
|
-
- [Quick Start](#quick-start)
|
|
11
|
-
- [Core Reactivity](#core-reactivity)
|
|
12
|
-
- [Observable](#observable)
|
|
13
|
-
- [ObservableArray](#observablearray)
|
|
14
|
-
- [Computed](#computed)
|
|
15
|
-
- [PureComputed](#purecomputed)
|
|
16
|
-
- [Effects](#effects)
|
|
17
|
-
- [Subscriptions](#subscriptions)
|
|
18
|
-
- [Events](#events)
|
|
19
|
-
- [Extenders](#extenders)
|
|
20
|
-
- [Decorators](#decorators)
|
|
21
|
-
- [@reactive](#reactive)
|
|
22
|
-
- [@reactiveArray](#reactivearray)
|
|
23
|
-
- [@computed](#computed-decorator)
|
|
24
|
-
- [getObservable / replaceObservable](#getobservable--replaceobservable)
|
|
25
|
-
- [Binding System](#binding-system)
|
|
26
|
-
- [Applying Bindings](#applying-bindings)
|
|
27
|
-
- [Binding Context](#binding-context)
|
|
28
|
-
- [Built-in Bindings](#built-in-bindings)
|
|
29
|
-
- [Virtual Elements](#virtual-elements)
|
|
30
|
-
- [Interpolation Markup](#interpolation-markup)
|
|
31
|
-
- [Attribute Interpolation](#attribute-interpolation)
|
|
32
|
-
- [Namespaced Bindings](#namespaced-bindings)
|
|
33
|
-
- [Filters](#filters)
|
|
34
|
-
- [Components](#components)
|
|
35
|
-
- [Registration](#registration)
|
|
36
|
-
- [The @component Decorator](#the-component-decorator)
|
|
37
|
-
- [Templates](#templates)
|
|
38
|
-
- [View Models](#view-models)
|
|
39
|
-
- [Params and Wiring](#params-and-wiring)
|
|
40
|
-
- [Lifecycle](#lifecycle)
|
|
41
|
-
- [Slots](#slots)
|
|
42
|
-
- [Custom Elements](#custom-elements)
|
|
43
|
-
- [Utilities](#utilities)
|
|
44
|
-
- [Configuration](#configuration)
|
|
45
|
-
- [Acknowledgments](#acknowledgments)
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
## Installation
|
|
50
|
-
|
|
51
|
-
```bash
|
|
52
|
-
npm install @thomas-siegfried/tapout
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
Tapout is ESM-only and requires a modern bundler or runtime that supports ES modules.
|
|
56
|
-
|
|
57
|
-
### Entry Points
|
|
58
|
-
|
|
59
|
-
Tapout provides two entry points for tree shaking:
|
|
60
|
-
|
|
61
|
-
```typescript
|
|
62
|
-
// Full library — reactivity + DOM bindings + templates + components
|
|
63
|
-
import { Observable, applyBindings, renderTemplate } from '@thomas-siegfried/tapout';
|
|
64
|
-
|
|
65
|
-
// Core only — reactivity primitives, no DOM dependencies
|
|
66
|
-
import { Observable, Computed, Event, effect } from '@thomas-siegfried/tapout/core';
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
| Entry Point | Includes |
|
|
70
|
-
| --- | --- |
|
|
71
|
-
| `@thomas-siegfried/tapout` | Everything: core reactivity, DOM bindings, templates, components, side-effect registrations |
|
|
72
|
-
| `@thomas-siegfried/tapout/core` | Observables, computed, subscriptions, events, effects, extenders, decorators, disposable, task scheduler, pure utilities |
|
|
73
|
-
|
|
74
|
-
Use `@thomas-siegfried/tapout/core` when you only need the reactive data layer — for example, in a Node.js backend, a shared library, or a project with its own rendering layer.
|
|
75
|
-
|
|
76
|
-
## Quick Start
|
|
77
|
-
|
|
78
|
-
```typescript
|
|
79
|
-
import { Observable, Computed, applyBindings } from '@thomas-siegfried/tapout';
|
|
80
|
-
|
|
81
|
-
class ViewModel {
|
|
82
|
-
firstName = new Observable('Jane');
|
|
83
|
-
lastName = new Observable('Doe');
|
|
84
|
-
fullName = new Computed(() => `${this.firstName.get()} ${this.lastName.get()}`);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
applyBindings(new ViewModel(), document.body);
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
```html
|
|
91
|
-
<p>First: <input data-bind="textInput: firstName" /></p>
|
|
92
|
-
<p>Last: <input data-bind="textInput: lastName" /></p>
|
|
93
|
-
<h2 data-bind="text: fullName"></h2>
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
Or with decorators:
|
|
97
|
-
|
|
98
|
-
```typescript
|
|
99
|
-
import { reactive, computed, applyBindings } from '@thomas-siegfried/tapout';
|
|
100
|
-
|
|
101
|
-
class ViewModel {
|
|
102
|
-
@reactive accessor firstName = 'Jane';
|
|
103
|
-
@reactive accessor lastName = 'Doe';
|
|
104
|
-
|
|
105
|
-
@computed get fullName() {
|
|
106
|
-
return `${this.firstName} ${this.lastName}`;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
applyBindings(new ViewModel(), document.body);
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
---
|
|
114
|
-
|
|
115
|
-
## Core Reactivity
|
|
116
|
-
|
|
117
|
-
### Observable
|
|
118
|
-
|
|
119
|
-
A mutable reactive value. Reading inside a computed or effect automatically registers a dependency.
|
|
120
|
-
|
|
121
|
-
```typescript
|
|
122
|
-
import { Observable } from '@thomas-siegfried/tapout';
|
|
123
|
-
|
|
124
|
-
const count = new Observable(0);
|
|
125
|
-
|
|
126
|
-
count.get(); // 0 — reads the value (tracks dependency)
|
|
127
|
-
count.peek(); // 0 — reads without tracking
|
|
128
|
-
count.set(5); // writes a new value, notifies subscribers
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
Setting the same primitive value again does not trigger notifications. For objects and arrays, every `set()` notifies regardless of reference equality. You can customize this behavior:
|
|
132
|
-
|
|
133
|
-
```typescript
|
|
134
|
-
count.equalityComparer = (a, b) => a === b;
|
|
135
|
-
count.equalityComparer = undefined; // always notify
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
Force a notification after in-place mutation:
|
|
139
|
-
|
|
140
|
-
```typescript
|
|
141
|
-
const data = new Observable({ name: 'Alice' });
|
|
142
|
-
data.peek().name = 'Bob';
|
|
143
|
-
data.valueHasMutated();
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
Type guard: `isObservable(value)`.
|
|
147
|
-
|
|
148
|
-
### ObservableArray
|
|
149
|
-
|
|
150
|
-
An `Observable<T[]>` with array-like methods that automatically notify on mutation.
|
|
151
|
-
|
|
152
|
-
```typescript
|
|
153
|
-
import { ObservableArray } from '@thomas-siegfried/tapout';
|
|
154
|
-
|
|
155
|
-
const items = new ObservableArray(['a', 'b', 'c']);
|
|
156
|
-
|
|
157
|
-
items.push('d');
|
|
158
|
-
items.remove('b');
|
|
159
|
-
items.splice(0, 1, 'x');
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
**Mutators** (all trigger change notifications): `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse`, `remove`, `removeAll`, `destroy`, `destroyAll`, `replace`.
|
|
163
|
-
|
|
164
|
-
**Readers** (all track dependencies): `length`, `indexOf`, `slice`, `sorted`, `reversed`, `map`, `filter`, `find`, `findIndex`, `some`, `every`, `forEach`, `reduce`, `includes`, `at`, `join`, `flat`, `flatMap`, `entries`, `keys`, `values`, `[Symbol.iterator]`.
|
|
165
|
-
|
|
166
|
-
**Array change tracking** — subscribe to the `arrayChange` event to receive fine-grained diffs:
|
|
167
|
-
|
|
168
|
-
```typescript
|
|
169
|
-
items.subscribe(changes => {
|
|
170
|
-
for (const change of changes) {
|
|
171
|
-
console.log(change.status, change.value, change.index);
|
|
172
|
-
}
|
|
173
|
-
}, 'arrayChange');
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
Each change has `status` (`'added'`, `'deleted'`, or `'retained'`), `value`, `index`, and optionally `moved`.
|
|
177
|
-
|
|
178
|
-
Type guard: `isObservableArray(value)`.
|
|
179
|
-
|
|
180
|
-
### Computed
|
|
181
|
-
|
|
182
|
-
A derived value that automatically re-evaluates when its dependencies change.
|
|
183
|
-
|
|
184
|
-
```typescript
|
|
185
|
-
import { Observable, Computed } from '@thomas-siegfried/tapout';
|
|
186
|
-
|
|
187
|
-
const width = new Observable(10);
|
|
188
|
-
const height = new Observable(20);
|
|
189
|
-
const area = new Computed(() => width.get() * height.get());
|
|
190
|
-
|
|
191
|
-
area.get(); // 200
|
|
192
|
-
width.set(5);
|
|
193
|
-
area.get(); // 100
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
Computed values only notify their subscribers when the result actually changes.
|
|
197
|
-
|
|
198
|
-
**Writable computed** — provide a `write` function to enable two-way usage:
|
|
199
|
-
|
|
200
|
-
```typescript
|
|
201
|
-
const first = new Observable('Jane');
|
|
202
|
-
const last = new Observable('Doe');
|
|
203
|
-
|
|
204
|
-
const full = new Computed({
|
|
205
|
-
read: () => `${first.get()} ${last.get()}`,
|
|
206
|
-
write: (value: string) => {
|
|
207
|
-
const [f, l] = value.split(' ');
|
|
208
|
-
first.set(f);
|
|
209
|
-
last.set(l);
|
|
210
|
-
},
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
full.set('John Smith'); // updates first and last
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
**Deferred evaluation** — delay the first evaluation until the value is actually needed:
|
|
217
|
-
|
|
218
|
-
```typescript
|
|
219
|
-
const lazy = new Computed({
|
|
220
|
-
read: () => expensiveCalc(),
|
|
221
|
-
deferEvaluation: true,
|
|
222
|
-
});
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
**Inspection:**
|
|
226
|
-
|
|
227
|
-
```typescript
|
|
228
|
-
area.getDependenciesCount(); // 2
|
|
229
|
-
area.getDependencies(); // [width, height]
|
|
230
|
-
area.isActive(); // true if it has dependencies
|
|
231
|
-
area.hasWriteFunction; // boolean
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
Call `area.dispose()` to stop tracking and release all dependency subscriptions.
|
|
235
|
-
|
|
236
|
-
Type guard: `isComputed(value)`.
|
|
237
|
-
|
|
238
|
-
### PureComputed
|
|
239
|
-
|
|
240
|
-
A memory-optimized computed that *sleeps* when it has no subscribers, releasing its dependency subscriptions. It *wakes* automatically when someone subscribes.
|
|
241
|
-
|
|
242
|
-
```typescript
|
|
243
|
-
import { PureComputed } from '@thomas-siegfried/tapout';
|
|
244
|
-
|
|
245
|
-
const label = new PureComputed(() => `Count: ${count.get()}`);
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
Use `PureComputed` for values that are only needed intermittently (e.g., computed values backing a UI that may or may not be in the DOM). It is the default backing for `@computed` decorators.
|
|
249
|
-
|
|
250
|
-
Type guards: `isPureComputed(value)`, `isComputed(value)`.
|
|
251
|
-
|
|
252
|
-
### Effects
|
|
253
|
-
|
|
254
|
-
Side-effect helpers that track reactive dependencies and re-run when they change.
|
|
255
|
-
|
|
256
|
-
```typescript
|
|
257
|
-
import { Observable, effect, observe } from '@thomas-siegfried/tapout';
|
|
258
|
-
|
|
259
|
-
const name = new Observable('Alice');
|
|
260
|
-
|
|
261
|
-
// effect: runs immediately, then again on each change
|
|
262
|
-
const handle = effect(
|
|
263
|
-
() => name.get(),
|
|
264
|
-
val => console.log(`Hello, ${val}!`),
|
|
265
|
-
);
|
|
266
|
-
// logs: "Hello, Alice!"
|
|
267
|
-
|
|
268
|
-
name.set('Bob');
|
|
269
|
-
// logs: "Hello, Bob!"
|
|
270
|
-
|
|
271
|
-
handle.dispose(); // stop watching
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
```typescript
|
|
275
|
-
// observe: does NOT run immediately — only fires on subsequent changes
|
|
276
|
-
const handle = observe(
|
|
277
|
-
() => name.get(),
|
|
278
|
-
val => console.log(`Changed to ${val}`),
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
name.set('Charlie');
|
|
282
|
-
// logs: "Changed to Charlie"
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
Both functions return an `EffectHandle`:
|
|
286
|
-
|
|
287
|
-
```typescript
|
|
288
|
-
interface EffectHandle {
|
|
289
|
-
dispose(): void;
|
|
290
|
-
}
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
Calling `dispose()` stops the effect, releasing the internal computed and its dependency subscriptions.
|
|
294
|
-
|
|
295
|
-
### Subscriptions
|
|
296
|
-
|
|
297
|
-
Every `Subscribable` (Observable, Computed, etc.) supports subscriptions:
|
|
298
|
-
|
|
299
|
-
```typescript
|
|
300
|
-
const sub = count.subscribe(value => {
|
|
301
|
-
console.log('New value:', value);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
sub.closed; // false
|
|
305
|
-
sub.dispose(); // unsubscribe
|
|
306
|
-
sub.closed; // true
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
**Events** — the second argument selects the event channel:
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
| Event | Fires when |
|
|
313
|
-
| -------------------- | ----------------------------------------------- |
|
|
314
|
-
| `'change'` (default) | After the value changes |
|
|
315
|
-
| `'beforeChange'` | Before a new value is written |
|
|
316
|
-
| `'spectate'` | On every write, regardless of equality |
|
|
317
|
-
| `'dirty'` | Synchronously when a deferred value is dirtied |
|
|
318
|
-
| `'awake'` | When a PureComputed wakes up |
|
|
319
|
-
| `'asleep'` | When a PureComputed goes to sleep |
|
|
320
|
-
| `'arrayChange'` | Fine-grained array diffs (ObservableArray only) |
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
```typescript
|
|
324
|
-
count.subscribe(oldVal => console.log('Was:', oldVal), 'beforeChange');
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
**Auto-dispose with DOM nodes:**
|
|
328
|
-
|
|
329
|
-
```typescript
|
|
330
|
-
sub.disposeWhenNodeIsRemoved(someElement);
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
The subscription is automatically disposed when the DOM node is cleaned or removed.
|
|
334
|
-
|
|
335
|
-
### Events
|
|
336
|
-
|
|
337
|
-
Events are stateless, hot signals — like observables that don't hold a value. They emit values on demand, and only active subscribers receive them.
|
|
338
|
-
|
|
339
|
-
An `Event` has a **two-sided** design (similar to Deferred/Promise): the owner calls `emit()`, and consumers receive the read-only `subscribable` side.
|
|
340
|
-
|
|
341
|
-
```typescript
|
|
342
|
-
import { Event } from '@thomas-siegfried/tapout';
|
|
343
|
-
|
|
344
|
-
class SaveEvent {
|
|
345
|
-
constructor(public id: number, public success: boolean) {}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
class MyService {
|
|
349
|
-
private _onSave = new Event<SaveEvent>();
|
|
350
|
-
readonly onSave = this._onSave.subscribable; // hand this out
|
|
351
|
-
|
|
352
|
-
save(id: number) {
|
|
353
|
-
// ... perform save ...
|
|
354
|
-
this._onSave.emit(new SaveEvent(id, true));
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const service = new MyService();
|
|
359
|
-
const sub = service.onSave.subscribe(e => {
|
|
360
|
-
console.log(`Saved item ${e.id}`);
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
service.save(42); // logs: Saved item 42
|
|
364
|
-
sub.dispose(); // stop listening
|
|
365
|
-
```
|
|
366
|
-
|
|
367
|
-
**Type-filtered subscriptions** — use `.on(Type)` to subscribe to only matching event types via `instanceof`:
|
|
368
|
-
|
|
369
|
-
```typescript
|
|
370
|
-
class DeleteEvent {
|
|
371
|
-
constructor(public id: number) {}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const event = new Event<SaveEvent | DeleteEvent>();
|
|
375
|
-
|
|
376
|
-
event.subscribable.on(SaveEvent).subscribe(e => {
|
|
377
|
-
console.log(`Save: ${e.id}`); // only SaveEvent instances
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
event.subscribable.on(DeleteEvent).subscribe(e => {
|
|
381
|
-
console.log(`Delete: ${e.id}`); // only DeleteEvent instances
|
|
382
|
-
});
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
**Aggregate events** — roll up multiple event sources into one, like DOM event bubbling through a tree:
|
|
386
|
-
|
|
387
|
-
```typescript
|
|
388
|
-
import { Event, AggregateEvent } from '@thomas-siegfried/tapout';
|
|
389
|
-
|
|
390
|
-
class ItemChangedEvent {
|
|
391
|
-
constructor(public itemId: number) {}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
class GrandChild {
|
|
395
|
-
private _onChange = new Event<ItemChangedEvent>();
|
|
396
|
-
readonly events = new AggregateEvent<ItemChangedEvent>();
|
|
397
|
-
|
|
398
|
-
constructor() {
|
|
399
|
-
this.events.pipe(this._onChange.subscribable);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
change(id: number) { this._onChange.emit(new ItemChangedEvent(id)); }
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
class Child {
|
|
406
|
-
private _onSave = new Event<SaveEvent>();
|
|
407
|
-
private _onDelete = new Event<DeleteEvent>();
|
|
408
|
-
readonly grandChild = new GrandChild();
|
|
409
|
-
|
|
410
|
-
// Roll up own events + grandchild's aggregate
|
|
411
|
-
readonly events = new AggregateEvent<SaveEvent | DeleteEvent | ItemChangedEvent>();
|
|
412
|
-
|
|
413
|
-
constructor() {
|
|
414
|
-
this.events.pipe(
|
|
415
|
-
this._onSave.subscribable,
|
|
416
|
-
this._onDelete.subscribable,
|
|
417
|
-
this.grandChild.events.subscribable,
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const child = new Child();
|
|
423
|
-
|
|
424
|
-
// Subscribe to everything in the tree
|
|
425
|
-
child.events.subscribable.subscribe(e => {
|
|
426
|
-
console.log('Something happened:', e);
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
// Or filter to a specific type
|
|
430
|
-
child.events.subscribable.on(SaveEvent).subscribe(e => {
|
|
431
|
-
console.log(`Save: ${e.id}`);
|
|
432
|
-
});
|
|
433
|
-
```
|
|
434
|
-
|
|
435
|
-
`pipe()` accepts multiple sources in a single call and can be called again to add sources later. It returns an array of `EventSubscription` objects for individual disposal.
|
|
436
|
-
|
|
437
|
-
Events are **hot** — no replay, no current value. If nobody is listening when `emit()` is called, the value is lost. Calling `dispose()` on an Event tears down all subscriptions (including piped sources on an `AggregateEvent`).
|
|
438
|
-
|
|
439
|
-
**DisposableGroup** — a utility for centralized subscription cleanup. Works with both `Subscription` (from observables) and `EventSubscription` (from events):
|
|
440
|
-
|
|
441
|
-
```typescript
|
|
442
|
-
import { DisposableGroup, Observable, Event } from '@thomas-siegfried/tapout';
|
|
443
|
-
|
|
444
|
-
class MyComponent {
|
|
445
|
-
private _subs = new DisposableGroup();
|
|
446
|
-
readonly count = new Observable(0);
|
|
447
|
-
|
|
448
|
-
constructor(events: EventSubscribable<SaveEvent>) {
|
|
449
|
-
this._subs.add(events.subscribe(e => this.onSave(e)));
|
|
450
|
-
this._subs.add(this.count.subscribe(v => console.log('Count:', v)));
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
dispose() {
|
|
454
|
-
this._subs.dispose(); // cleans up all subscriptions at once
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
`add()` returns the disposable, so you can still hold a reference for early individual disposal. Any items added after the group is already disposed are immediately disposed.
|
|
460
|
-
|
|
461
|
-
---
|
|
462
|
-
|
|
463
|
-
## Extenders
|
|
464
|
-
|
|
465
|
-
Extenders modify the notification behavior of any subscribable.
|
|
466
|
-
|
|
467
|
-
```typescript
|
|
468
|
-
const search = new Observable('');
|
|
469
|
-
|
|
470
|
-
// Debounce: wait until changes stop for 300ms before notifying
|
|
471
|
-
search.extend({ rateLimit: { timeout: 300, method: 'notifyWhenChangesStop' } });
|
|
472
|
-
|
|
473
|
-
// Throttle: notify at most once every 200ms (default method)
|
|
474
|
-
search.extend({ rateLimit: 200 });
|
|
475
|
-
|
|
476
|
-
// Always notify, even when value hasn't changed
|
|
477
|
-
search.extend({ notify: 'always' });
|
|
478
|
-
|
|
479
|
-
// Defer notifications to the microtask queue
|
|
480
|
-
search.extend({ deferred: true });
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
`extend()` returns the same instance, so calls can be chained.
|
|
484
|
-
|
|
485
|
-
### Custom Extenders
|
|
486
|
-
|
|
487
|
-
```typescript
|
|
488
|
-
import { registerExtender } from '@thomas-siegfried/tapout';
|
|
489
|
-
|
|
490
|
-
registerExtender('logChanges', (target, label) => {
|
|
491
|
-
target.subscribe(val => console.log(`[${label}]`, val));
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
const obs = new Observable(0);
|
|
495
|
-
obs.extend({ logChanges: 'myObs' });
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
### Global Deferred Updates
|
|
499
|
-
|
|
500
|
-
Enable deferred notifications for all new observables and computeds:
|
|
501
|
-
|
|
502
|
-
```typescript
|
|
503
|
-
import { options } from '@thomas-siegfried/tapout';
|
|
504
|
-
|
|
505
|
-
options.deferUpdates = true;
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
When enabled, multiple synchronous writes are batched into a single notification on the next microtask.
|
|
509
|
-
|
|
510
|
-
---
|
|
511
|
-
|
|
512
|
-
## Decorators
|
|
513
|
-
|
|
514
|
-
Tapout provides TC39 Stage 3 class decorators for a cleaner syntax. These work with TypeScript 5.0+ and the `--experimentalDecorators` flag is **not** needed — these are native decorators.
|
|
515
|
-
|
|
516
|
-
### @reactive
|
|
517
|
-
|
|
518
|
-
Turns a class accessor into an `Observable`-backed property.
|
|
519
|
-
|
|
520
|
-
```typescript
|
|
521
|
-
import { reactive } from '@thomas-siegfried/tapout';
|
|
522
|
-
|
|
523
|
-
class Settings {
|
|
524
|
-
@reactive accessor theme = 'dark';
|
|
525
|
-
@reactive accessor fontSize = 14;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const s = new Settings();
|
|
529
|
-
s.theme; // 'dark' (reads the Observable, tracks dependency)
|
|
530
|
-
s.theme = 'light'; // writes through to the Observable, notifies subscribers
|
|
531
|
-
```
|
|
532
|
-
|
|
533
|
-
Pass extender options:
|
|
534
|
-
|
|
535
|
-
```typescript
|
|
536
|
-
@reactive({ notify: 'always' }) accessor tag = '';
|
|
537
|
-
@reactive({ deferred: true }) accessor query = '';
|
|
538
|
-
```
|
|
539
|
-
|
|
540
|
-
### @reactiveArray
|
|
541
|
-
|
|
542
|
-
Turns a class accessor into an `ObservableArray`-backed property.
|
|
543
|
-
|
|
544
|
-
```typescript
|
|
545
|
-
import { reactiveArray } from '@thomas-siegfried/tapout';
|
|
546
|
-
|
|
547
|
-
class TodoList {
|
|
548
|
-
@reactiveArray accessor items: string[] = [];
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
const list = new TodoList();
|
|
552
|
-
(list.items as any).push('Buy milk'); // mutates the ObservableArray
|
|
553
|
-
list.items.length; // 1
|
|
554
|
-
```
|
|
555
|
-
|
|
556
|
-
The getter returns the `ObservableArray` instance directly. The setter calls `set()` on the underlying array.
|
|
557
|
-
|
|
558
|
-
### @computed (decorator)
|
|
559
|
-
|
|
560
|
-
Works on getters, getter+setter pairs, and methods.
|
|
561
|
-
|
|
562
|
-
```typescript
|
|
563
|
-
import { reactive, computed } from '@thomas-siegfried/tapout';
|
|
564
|
-
|
|
565
|
-
class FullName {
|
|
566
|
-
@reactive accessor first = 'John';
|
|
567
|
-
@reactive accessor last = 'Doe';
|
|
568
|
-
|
|
569
|
-
@computed get full() {
|
|
570
|
-
return `${this.first} ${this.last}`;
|
|
571
|
-
}
|
|
572
|
-
set full(v: string) {
|
|
573
|
-
[this.first, this.last] = v.split(' ');
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
@computed nameLength() {
|
|
577
|
-
return this.first.length + this.last.length;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
```
|
|
581
|
-
|
|
582
|
-
- Getters create a read-only `Computed` per instance (with `deferEvaluation: true`)
|
|
583
|
-
- Getter+setter pairs create a writable `Computed`
|
|
584
|
-
- Methods become computed-backed — calling returns the computed value
|
|
585
|
-
|
|
586
|
-
### getObservable / replaceObservable
|
|
587
|
-
|
|
588
|
-
Retrieve or replace the underlying reactive primitive for a decorated property:
|
|
589
|
-
|
|
590
|
-
```typescript
|
|
591
|
-
import { getObservable, replaceObservable } from '@thomas-siegfried/tapout';
|
|
592
|
-
|
|
593
|
-
const obs = getObservable(instance, 'theme');
|
|
594
|
-
// returns the Observable, ObservableArray, or Computed backing the property
|
|
595
|
-
|
|
596
|
-
replaceObservable(instance, 'theme', anotherObservable);
|
|
597
|
-
// swap the backing observable (used internally by wireParams for two-way binding)
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
---
|
|
601
|
-
|
|
602
|
-
## Binding System
|
|
603
|
-
|
|
604
|
-
### Applying Bindings
|
|
605
|
-
|
|
606
|
-
```typescript
|
|
607
|
-
import { applyBindings } from '@thomas-siegfried/tapout';
|
|
608
|
-
|
|
609
|
-
const vm = new ViewModel();
|
|
610
|
-
applyBindings(vm, document.getElementById('app'));
|
|
611
|
-
```
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
| Function | Purpose |
|
|
615
|
-
| ------------------------------------------ | --------------------------------------- |
|
|
616
|
-
| `applyBindings(vm, rootNode)` | Bind a view model to a DOM subtree |
|
|
617
|
-
| `applyBindingsToDescendants(vm, rootNode)` | Bind only the children, not the root |
|
|
618
|
-
| `applyBindingsToNode(node, bindings, vm?)` | Bind specific bindings to a single node |
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
### Binding Context
|
|
622
|
-
|
|
623
|
-
Inside bindings, the following context properties are available:
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
| Property | Description |
|
|
627
|
-
| ------------------------- | --------------------------------------------- |
|
|
628
|
-
| `$data` | The current data item |
|
|
629
|
-
| `$rawData` | The raw (possibly observable) data |
|
|
630
|
-
| `$root` | The root view model |
|
|
631
|
-
| `$parent` | The parent's `$data` |
|
|
632
|
-
| `$parentContext` | The parent binding context |
|
|
633
|
-
| `$parents` | Array of all ancestor `$data` values |
|
|
634
|
-
| `$index` | Current item index (inside `foreach`) |
|
|
635
|
-
| `$component` | The component view model (inside components) |
|
|
636
|
-
| `$componentTemplateNodes` | Original child nodes of the component element |
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
Inspect the context of a DOM node programmatically:
|
|
640
|
-
|
|
641
|
-
```typescript
|
|
642
|
-
import { contextFor, dataFor } from '@thomas-siegfried/tapout';
|
|
643
|
-
|
|
644
|
-
const ctx = contextFor(someElement); // BindingContext
|
|
645
|
-
const data = dataFor(someElement); // $data
|
|
646
|
-
```
|
|
647
|
-
|
|
648
|
-
### Built-in Bindings
|
|
649
|
-
|
|
650
|
-
#### Display
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
| Binding | Example | Description |
|
|
654
|
-
| --------- | ------------------------------- | ----------------------- |
|
|
655
|
-
| `text` | `data-bind="text: message"` | Sets text content |
|
|
656
|
-
| `html` | `data-bind="html: richContent"` | Sets innerHTML |
|
|
657
|
-
| `visible` | `data-bind="visible: isShown"` | Toggles `display: none` |
|
|
658
|
-
| `hidden` | `data-bind="hidden: isShown"` | Inverse of `visible` |
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
#### Attributes
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
| Binding | Examplee | Description |
|
|
665
|
-
| ------- | --------------------------------------------- | ------------------------------- |
|
|
666
|
-
| `attr` | `data-bind="attr: { href: url, title: tip }"` | Sets/removes attributes |
|
|
667
|
-
| `css` | `data-bind="css: { active: isActive }"` | Toggles CSS classes |
|
|
668
|
-
| `class` | `data-bind="class: className"` | Sets the class attribute string |
|
|
669
|
-
| `style` | `data-bind="style: { color: textColor }"` | Sets inline styles |
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
#### Form State
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
| Binding | Example | Description |
|
|
676
|
-
| ------------ | --------------------------------- | ---------------------------------------- |
|
|
677
|
-
| `enable` | `data-bind="enable: canSubmit"` | Enables/disables the element |
|
|
678
|
-
| `disable` | `data-bind="disable: isReadOnly"` | Inverse of `enable` |
|
|
679
|
-
| `uniqueName` | `data-bind="uniqueName: true"` | Auto-generates a unique `name` attribute |
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
#### Events
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
| Binding | Example | Description |
|
|
686
|
-
| ---------- | ------------------------------------------- | -------------------------------------- |
|
|
687
|
-
| `event` | `data-bind="event: { mouseover: onHover }"` | Binds one or more event handlers |
|
|
688
|
-
| `click` | `data-bind="click: onClick"` | Shorthand for click events |
|
|
689
|
-
| `submit` | `data-bind="submit: onSubmit"` | Form submit handler (prevents default) |
|
|
690
|
-
| `keydown` | `data-bind="keydown.enter: onEnter"` | Key-filtered keydown (see below) |
|
|
691
|
-
| `keyup` | `data-bind="keyup.space: onSpace"` | Key-filtered keyup (see below) |
|
|
692
|
-
| `enter` | `data-bind="enter: onEnter"` | Shorthand for `keydown.enter` |
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
Event handlers receive `$data` as the first argument and the DOM event as the second. Return `true` from a handler to allow default browser behavior. Control bubbling with `eventNameBubble: false`:
|
|
696
|
-
|
|
697
|
-
```html
|
|
698
|
-
<button data-bind="click: onClick, clickBubble: false">Click</button>
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
##### Key Event Bindings
|
|
702
|
-
|
|
703
|
-
The `keydown` and `keyup` bindings use the namespaced binding system to filter by key and modifier. The part after the first dot is the key name, and additional dots add modifier requirements:
|
|
704
|
-
|
|
705
|
-
```html
|
|
706
|
-
<!-- Single key -->
|
|
707
|
-
<input data-bind="keydown.enter: handleEnter">
|
|
708
|
-
<input data-bind="keydown.esc: handleEsc">
|
|
709
|
-
<input data-bind="keyup.tab: handleTab">
|
|
710
|
-
|
|
711
|
-
<!-- Key + modifiers -->
|
|
712
|
-
<input data-bind="keydown.enter.ctrl: handleCtrlEnter">
|
|
713
|
-
<input data-bind="keydown.s.ctrl: handleSave">
|
|
714
|
-
<input data-bind="keydown.enter.ctrl.shift: handleCtrlShiftEnter">
|
|
715
|
-
|
|
716
|
-
<!-- Plain (no filter, fires on any keydown) -->
|
|
717
|
-
<input data-bind="keydown: handleAnyKey">
|
|
718
|
-
```
|
|
719
|
-
|
|
720
|
-
**Key aliases** — use these shorthand names or any raw `KeyboardEvent.key` value (e.g. `a`, `s`, `F1`):
|
|
721
|
-
|
|
722
|
-
| Alias | `KeyboardEvent.key` |
|
|
723
|
-
| ----------- | -------------------- |
|
|
724
|
-
| `enter` | `Enter` |
|
|
725
|
-
| `tab` | `Tab` |
|
|
726
|
-
| `esc` | `Escape` |
|
|
727
|
-
| `escape` | `Escape` |
|
|
728
|
-
| `space` | ` ` (space) |
|
|
729
|
-
| `delete` | `Delete` |
|
|
730
|
-
| `backspace` | `Backspace` |
|
|
731
|
-
| `up` | `ArrowUp` |
|
|
732
|
-
| `down` | `ArrowDown` |
|
|
733
|
-
| `left` | `ArrowLeft` |
|
|
734
|
-
| `right` | `ArrowRight` |
|
|
735
|
-
|
|
736
|
-
**Modifiers** — append any combination of `ctrl`, `alt`, `shift`, `meta`:
|
|
737
|
-
|
|
738
|
-
```html
|
|
739
|
-
<input data-bind="keydown.enter.alt: onAltEnter">
|
|
740
|
-
<input data-bind="keydown.s.ctrl.shift: onCtrlShiftS">
|
|
741
|
-
```
|
|
742
|
-
|
|
743
|
-
#### Form Values (Two-Way)
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
| Binding | Example | Description |
|
|
747
|
-
| ----------------- | ------------------------------------- | ------------------------------------------- |
|
|
748
|
-
| `value` | `data-bind="value: selectedItem"` | Two-way value binding (updates on `change`) |
|
|
749
|
-
| `textInput` | `data-bind="textInput: query"` | Live text binding (updates on `input`) |
|
|
750
|
-
| `checked` | `data-bind="checked: isAgreed"` | Checkbox/radio two-way binding |
|
|
751
|
-
| `checkedValue` | `data-bind="checkedValue: itemId"` | Sets the value sent when checked |
|
|
752
|
-
| `hasFocus` | `data-bind="hasFocus: isFocused"` | Two-way focus binding |
|
|
753
|
-
| `selectedOptions` | `data-bind="selectedOptions: chosen"` | Multi-select binding |
|
|
754
|
-
| `options` | `data-bind="options: items"` | Populates a `<select>` from an array |
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
The `options` binding supports additional parameters:
|
|
758
|
-
|
|
759
|
-
```html
|
|
760
|
-
<select data-bind="options: people,
|
|
761
|
-
optionsText: 'name',
|
|
762
|
-
optionsValue: 'id',
|
|
763
|
-
optionsCaption: 'Choose...',
|
|
764
|
-
value: selectedPersonId">
|
|
765
|
-
</select>
|
|
766
|
-
```
|
|
767
|
-
|
|
768
|
-
The `checked` binding supports array mode for checkbox groups:
|
|
769
|
-
|
|
770
|
-
```html
|
|
771
|
-
<input type="checkbox" data-bind="checked: selectedColors, checkedValue: 'red'" />
|
|
772
|
-
<input type="checkbox" data-bind="checked: selectedColors, checkedValue: 'blue'" />
|
|
773
|
-
```
|
|
774
|
-
|
|
775
|
-
#### Control Flow
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
| Binding | Example | Description |
|
|
779
|
-
| ---------- | ---------------------------------------- | ------------------------------------------ |
|
|
780
|
-
| `if` | `data-bind="if: isLoggedIn"` | Conditionally renders content |
|
|
781
|
-
| `ifnot` | `data-bind="ifnot: isEmpty"` | Inverse conditional |
|
|
782
|
-
| `with` | `data-bind="with: selectedItem"` | Creates a child context; hides when falsy |
|
|
783
|
-
| `using` | `data-bind="using: config"` | Like `with` but always renders |
|
|
784
|
-
| `let` | `data-bind="let: { x: computedVal }"` | Extends context with additional properties |
|
|
785
|
-
| `foreach` | `data-bind="foreach: items"` | Iterates over an array |
|
|
786
|
-
| `template` | `data-bind="template: { name: 'tmpl' }"` | Renders a named or anonymous template |
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
The `foreach` binding creates a child context for each item with `$data`, `$index`, and `$parent`:
|
|
790
|
-
|
|
791
|
-
```html
|
|
792
|
-
<ul data-bind="foreach: people">
|
|
793
|
-
<li>
|
|
794
|
-
<span data-bind="text: $index"></span>:
|
|
795
|
-
<span data-bind="text: name"></span>
|
|
796
|
-
</li>
|
|
797
|
-
</ul>
|
|
798
|
-
```
|
|
799
|
-
|
|
800
|
-
Use `as` to alias the item:
|
|
801
|
-
|
|
802
|
-
```html
|
|
803
|
-
<ul data-bind="foreach: { data: people, as: 'person' }">
|
|
804
|
-
<li data-bind="text: person.name"></li>
|
|
805
|
-
</ul>
|
|
806
|
-
```
|
|
807
|
-
|
|
808
|
-
#### Dialog
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
| Binding | Example | Description |
|
|
812
|
-
| ------- | --------------------------------- | --------------------------------------------- |
|
|
813
|
-
| `modal` | `data-bind="modal: isDialogOpen"` | Calls `showModal()`/`close()` on a `<dialog>` |
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
### Virtual Elements
|
|
817
|
-
|
|
818
|
-
Use HTML comments for bindings that don't need a wrapper element:
|
|
819
|
-
|
|
820
|
-
```html
|
|
821
|
-
<!-- tap if: showSection -->
|
|
822
|
-
<p>Conditional content</p>
|
|
823
|
-
<!-- /tap -->
|
|
824
|
-
|
|
825
|
-
<!-- tap foreach: items -->
|
|
826
|
-
<span data-bind="text: $data"></span>
|
|
827
|
-
<!-- /tap -->
|
|
828
|
-
```
|
|
829
|
-
|
|
830
|
-
The following bindings support virtual elements: `text`, `html`, `if`, `ifnot`, `with`, `let`, `using`, `foreach`, `template`, `component`, `slot`.
|
|
831
|
-
|
|
832
|
-
### Configuration
|
|
833
|
-
|
|
834
|
-
Tapout has several opt-in features that enhance the binding system. Enable them declaratively via `options`, or all at once with `enableAll()`:
|
|
835
|
-
|
|
836
|
-
```typescript
|
|
837
|
-
import { options } from '@thomas-siegfried/tapout';
|
|
838
|
-
|
|
839
|
-
options.interpolation = true; // {{ }} text interpolation
|
|
840
|
-
options.attributeInterpolation = true; // {{ }} inside HTML attributes
|
|
841
|
-
options.namespacedBindings = true; // Dot-notation: attr.href, keydown.enter
|
|
842
|
-
options.filters = true; // Pipe filters on all bindings
|
|
843
|
-
```
|
|
844
|
-
|
|
845
|
-
Features are activated automatically on the first `applyBindings` call. Or enable everything at once:
|
|
846
|
-
|
|
847
|
-
```typescript
|
|
848
|
-
import { enableAll } from '@thomas-siegfried/tapout';
|
|
849
|
-
enableAll();
|
|
850
|
-
```
|
|
851
|
-
|
|
852
|
-
The individual enable functions (`enableInterpolationMarkup`, `enableNamespacedBindings`, etc.) still work for granular control.
|
|
853
|
-
|
|
854
|
-
The `options` object also includes runtime settings:
|
|
855
|
-
|
|
856
|
-
| Option | Default | Description |
|
|
857
|
-
| ---------------------------- | ------------------- | ------------------------------------------------ |
|
|
858
|
-
| `deferUpdates` | `false` | Batch writes into microtask notifications |
|
|
859
|
-
| `onError` | `null` | Global error handler for bindings/computeds |
|
|
860
|
-
| `viewModelFactory` | `(ctor) => new ctor()` | Factory for component view model instantiation |
|
|
861
|
-
| `customElementDisplayContents` | `true` | Custom elements use `display: contents` |
|
|
862
|
-
|
|
863
|
-
### Interpolation Markup
|
|
864
|
-
|
|
865
|
-
Enable text interpolation for a more template-like syntax (or set `options.interpolation = true`):
|
|
866
|
-
|
|
867
|
-
```typescript
|
|
868
|
-
import { enableInterpolationMarkup } from '@thomas-siegfried/tapout';
|
|
869
|
-
enableInterpolationMarkup();
|
|
870
|
-
```
|
|
871
|
-
|
|
872
|
-
Then use `{{ }}` in your HTML:
|
|
873
|
-
|
|
874
|
-
```html
|
|
875
|
-
<span>Hello, {{ name }}!</span>
|
|
876
|
-
|
|
877
|
-
<!-- Raw HTML (triple braces) -->
|
|
878
|
-
<div>{{{ richContent }}}</div>
|
|
879
|
-
|
|
880
|
-
<!-- Block control flow -->
|
|
881
|
-
{{# if isLoggedIn }}
|
|
882
|
-
<p>Welcome back, {{ username }}!</p>
|
|
883
|
-
{{/ if }}
|
|
884
|
-
```
|
|
885
|
-
|
|
886
|
-
### Attribute Interpolation
|
|
887
|
-
|
|
888
|
-
Enable interpolation inside HTML attributes (or set `options.attributeInterpolation = true`):
|
|
889
|
-
|
|
890
|
-
```typescript
|
|
891
|
-
import { enableAttributeInterpolationMarkup } from '@thomas-siegfried/tapout';
|
|
892
|
-
enableAttributeInterpolationMarkup();
|
|
893
|
-
```
|
|
894
|
-
|
|
895
|
-
```html
|
|
896
|
-
<a href="{{ baseUrl }}/profile/{{ userId }}">Profile</a>
|
|
897
|
-
<img title="Photo of {{ name }}" />
|
|
898
|
-
```
|
|
899
|
-
|
|
900
|
-
### Namespaced Bindings
|
|
901
|
-
|
|
902
|
-
Enable shorthand dot-notation for attribute-like bindings (or set `options.namespacedBindings = true`):
|
|
903
|
-
|
|
904
|
-
```typescript
|
|
905
|
-
import { enableNamespacedBindings } from '@thomas-siegfried/tapout';
|
|
906
|
-
enableNamespacedBindings();
|
|
907
|
-
```
|
|
908
|
-
|
|
909
|
-
```html
|
|
910
|
-
<a data-bind="attr.href: profileUrl, css.active: isSelected">Link</a>
|
|
911
|
-
<div data-bind="style.color: textColor, event.click: onClick"></div>
|
|
912
|
-
```
|
|
913
|
-
|
|
914
|
-
### Filters
|
|
915
|
-
|
|
916
|
-
Add pipe-style filters to binding values. Set `options.filters = true` to enable on all bindings, or use `enableTextFilter` for specific ones:
|
|
917
|
-
|
|
918
|
-
```typescript
|
|
919
|
-
import { enableTextFilter } from '@thomas-siegfried/tapout';
|
|
920
|
-
|
|
921
|
-
enableTextFilter('text');
|
|
922
|
-
enableTextFilter('html');
|
|
923
|
-
```
|
|
924
|
-
|
|
925
|
-
```html
|
|
926
|
-
<span data-bind="text: name | uppercase"></span>
|
|
927
|
-
<span data-bind="text: bio | default:'No bio provided'"></span>
|
|
928
|
-
<span data-bind="text: data | json"></span>
|
|
929
|
-
```
|
|
930
|
-
|
|
931
|
-
**Built-in filters:** `uppercase`, `lowercase`, `default`, `json`.
|
|
932
|
-
|
|
933
|
-
**Custom filters:**
|
|
934
|
-
|
|
935
|
-
```typescript
|
|
936
|
-
import { filters } from '@thomas-siegfried/tapout';
|
|
937
|
-
|
|
938
|
-
filters['truncate'] = (value: string, maxLength: number) => {
|
|
939
|
-
return value.length > maxLength ? value.slice(0, maxLength) + '...' : value;
|
|
940
|
-
};
|
|
941
|
-
```
|
|
942
|
-
|
|
943
|
-
```html
|
|
944
|
-
<span data-bind="text: description | truncate:100"></span>
|
|
945
|
-
```
|
|
946
|
-
|
|
947
|
-
Filters work in both `data-bind` attributes and `{{ }}` interpolation.
|
|
948
|
-
|
|
949
|
-
---
|
|
950
|
-
|
|
951
|
-
## Components
|
|
952
|
-
|
|
953
|
-
### Registration
|
|
954
|
-
|
|
955
|
-
```typescript
|
|
956
|
-
import { components } from '@thomas-siegfried/tapout';
|
|
957
|
-
|
|
958
|
-
components.register('user-card', {
|
|
959
|
-
template: '<div><span data-bind="text: name"></span></div>',
|
|
960
|
-
viewModel: UserCardViewModel,
|
|
961
|
-
synchronous: true,
|
|
962
|
-
});
|
|
963
|
-
```
|
|
964
|
-
|
|
965
|
-
Use them in HTML:
|
|
966
|
-
|
|
967
|
-
```html
|
|
968
|
-
<user-card params="name: userName"></user-card>
|
|
969
|
-
```
|
|
970
|
-
|
|
971
|
-
Or with the `component` binding:
|
|
972
|
-
|
|
973
|
-
```html
|
|
974
|
-
<div data-bind="component: { name: 'user-card', params: { name: userName } }"></div>
|
|
975
|
-
```
|
|
976
|
-
|
|
977
|
-
### The @component Decorator
|
|
978
|
-
|
|
979
|
-
Register components declaratively:
|
|
980
|
-
|
|
981
|
-
```typescript
|
|
982
|
-
import { component, reactive } from '@thomas-siegfried/tapout';
|
|
983
|
-
|
|
984
|
-
@component('user-card', '<div><span data-bind="text: name"></span></div>')
|
|
985
|
-
class UserCard {
|
|
986
|
-
@reactive accessor name = '';
|
|
987
|
-
}
|
|
988
|
-
```
|
|
989
|
-
|
|
990
|
-
Or with options:
|
|
991
|
-
|
|
992
|
-
```typescript
|
|
993
|
-
@component({ tag: 'user-card', template: '<div>...</div>', synchronous: true })
|
|
994
|
-
class UserCard { ... }
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
Retrieve the tag from a class or instance:
|
|
998
|
-
|
|
999
|
-
```typescript
|
|
1000
|
-
import { getComponentTag } from '@thomas-siegfried/tapout';
|
|
1001
|
-
getComponentTag(UserCard); // 'user-card'
|
|
1002
|
-
getComponentTag(new UserCard()); // 'user-card'
|
|
1003
|
-
```
|
|
1004
|
-
|
|
1005
|
-
### Templates
|
|
1006
|
-
|
|
1007
|
-
Templates can be provided as:
|
|
1008
|
-
|
|
1009
|
-
- A **string** of HTML
|
|
1010
|
-
- A **DOM element** or `DocumentFragment`
|
|
1011
|
-
- An **array of nodes**
|
|
1012
|
-
- `{ element: 'template-id' }` — references a `<template>`, `<script>`, or other element by ID
|
|
1013
|
-
|
|
1014
|
-
### View Models
|
|
1015
|
-
|
|
1016
|
-
The `viewModel` config accepts:
|
|
1017
|
-
|
|
1018
|
-
- A **class constructor** — a new instance is created per component
|
|
1019
|
-
- `{ createViewModel(params, componentInfo) }` — factory function
|
|
1020
|
-
- `{ instance: existingObject }` — shared singleton instance
|
|
1021
|
-
|
|
1022
|
-
### Params and Wiring
|
|
1023
|
-
|
|
1024
|
-
Pass parameters to components via the `params` attribute:
|
|
1025
|
-
|
|
1026
|
-
```html
|
|
1027
|
-
<user-card params="name: userName, age: 30"></user-card>
|
|
1028
|
-
```
|
|
1029
|
-
|
|
1030
|
-
Inside the component, `wireParams` connects parameters to `@reactive` properties:
|
|
1031
|
-
|
|
1032
|
-
- **Plain values** are assigned directly
|
|
1033
|
-
- **Computed params** create a one-way subscription (parent updates flow to child)
|
|
1034
|
-
- **Observable params** with `$`-prefix enable two-way binding by sharing the backing observable
|
|
1035
|
-
|
|
1036
|
-
```html
|
|
1037
|
-
<!-- Two-way: child and parent share the same Observable -->
|
|
1038
|
-
<user-card params="name: $sharedName"></user-card>
|
|
1039
|
-
```
|
|
1040
|
-
|
|
1041
|
-
The `$`-prefix syntax passes the parent's raw `Observable` so both sides read and write the same value.
|
|
1042
|
-
|
|
1043
|
-
You can also call `wireParams` manually:
|
|
1044
|
-
|
|
1045
|
-
```typescript
|
|
1046
|
-
import { wireParams } from '@thomas-siegfried/tapout';
|
|
1047
|
-
|
|
1048
|
-
const result = wireParams(viewModelInstance, params);
|
|
1049
|
-
// result.subscriptions — array of subscriptions to dispose later
|
|
1050
|
-
```
|
|
1051
|
-
|
|
1052
|
-
### Lifecycle
|
|
1053
|
-
|
|
1054
|
-
Component view models can implement these lifecycle methods:
|
|
1055
|
-
|
|
1056
|
-
1. `**onInit()**` — Called after VM creation and param wiring, before template binding. Use for initial setup.
|
|
1057
|
-
2. `**onDescendantsComplete(node)**` — Called after all descendant bindings are complete. Good for DOM measurement or third-party widget initialization.
|
|
1058
|
-
3. `**dispose()**` — Called when the component's DOM node is cleaned. Use for cleanup.
|
|
1059
|
-
|
|
1060
|
-
```typescript
|
|
1061
|
-
@component('my-widget', '<div>...</div>')
|
|
1062
|
-
class MyWidget {
|
|
1063
|
-
@reactive accessor data = '';
|
|
1064
|
-
|
|
1065
|
-
onInit() {
|
|
1066
|
-
// fetch initial data, set up state
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
onDescendantsComplete(node: Node) {
|
|
1070
|
-
// DOM is ready, descendants are bound
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
dispose() {
|
|
1074
|
-
// clean up subscriptions, timers, etc.
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
```
|
|
1078
|
-
|
|
1079
|
-
Execution order: `onInit` → template binding → `onDescendantsComplete` → (on cleanup) → `dispose`.
|
|
1080
|
-
|
|
1081
|
-
### Slots
|
|
1082
|
-
|
|
1083
|
-
Project content from a component's consumer into its template using the `slot` binding.
|
|
1084
|
-
|
|
1085
|
-
**Component template:**
|
|
1086
|
-
|
|
1087
|
-
```html
|
|
1088
|
-
<div class="card">
|
|
1089
|
-
<header data-bind="slot: 'header'">Default Header</header>
|
|
1090
|
-
<div data-bind="slot: ''">Default body content</div>
|
|
1091
|
-
<footer data-bind="slot: 'footer'">Default Footer</footer>
|
|
1092
|
-
</div>
|
|
1093
|
-
```
|
|
1094
|
-
|
|
1095
|
-
**Usage:**
|
|
1096
|
-
|
|
1097
|
-
```html
|
|
1098
|
-
<my-card>
|
|
1099
|
-
<h2 slot="header">Custom Title</h2>
|
|
1100
|
-
<p>This goes into the default slot.</p>
|
|
1101
|
-
<small slot="footer">Custom footer</small>
|
|
1102
|
-
</my-card>
|
|
1103
|
-
```
|
|
1104
|
-
|
|
1105
|
-
Slotted content binds in the **parent** context, not the component's context. Fallback content inside the slot binding is shown when no matching content is provided.
|
|
1106
|
-
|
|
1107
|
-
### Custom Elements
|
|
1108
|
-
|
|
1109
|
-
Any registered component is automatically detected as a custom element. Tapout applies `display: contents` to custom elements by default so they don't affect layout. Disable this with:
|
|
1110
|
-
|
|
1111
|
-
```typescript
|
|
1112
|
-
import { options } from '@thomas-siegfried/tapout';
|
|
1113
|
-
options.customElementDisplayContents = false;
|
|
1114
|
-
```
|
|
1115
|
-
|
|
1116
|
-
---
|
|
1117
|
-
|
|
1118
|
-
## Utilities
|
|
1119
|
-
|
|
1120
|
-
### toJS / toJSON
|
|
1121
|
-
|
|
1122
|
-
Deep-unwrap all observables in an object graph:
|
|
1123
|
-
|
|
1124
|
-
```typescript
|
|
1125
|
-
import { toJS, toJSON } from '@thomas-siegfried/tapout';
|
|
1126
|
-
|
|
1127
|
-
const plain = toJS(viewModel); // plain JS object, no observables
|
|
1128
|
-
const json = toJSON(viewModel, null, 2); // JSON string
|
|
1129
|
-
```
|
|
1130
|
-
|
|
1131
|
-
Handles nested objects, arrays, `ObservableArray`, `Date`, `RegExp`, and circular references.
|
|
1132
|
-
|
|
1133
|
-
### when
|
|
1134
|
-
|
|
1135
|
-
Wait for a reactive condition to become truthy:
|
|
1136
|
-
|
|
1137
|
-
```typescript
|
|
1138
|
-
import { when } from '@thomas-siegfried/tapout';
|
|
1139
|
-
|
|
1140
|
-
// With callback — returns a disposable Subscription
|
|
1141
|
-
const sub = when(() => items.length > 0, () => {
|
|
1142
|
-
console.log('Items loaded!');
|
|
1143
|
-
});
|
|
1144
|
-
|
|
1145
|
-
// Without callback — returns a Promise
|
|
1146
|
-
await when(() => isReady.get());
|
|
1147
|
-
```
|
|
1148
|
-
|
|
1149
|
-
`when` is one-shot: once the condition is met, the subscription is automatically disposed.
|
|
1150
|
-
|
|
1151
|
-
### unwrapObservable / peekObservable
|
|
1152
|
-
|
|
1153
|
-
```typescript
|
|
1154
|
-
import { unwrapObservable, peekObservable } from '@thomas-siegfried/tapout';
|
|
1155
|
-
|
|
1156
|
-
unwrapObservable(obs); // recursively unwraps observables (up to 10 levels)
|
|
1157
|
-
peekObservable(obs); // same but uses peek() — no dependency tracking
|
|
1158
|
-
```
|
|
1159
|
-
|
|
1160
|
-
### DOM Utilities
|
|
1161
|
-
|
|
1162
|
-
```typescript
|
|
1163
|
-
import { cleanNode, removeNode, addDisposeCallback } from '@thomas-siegfried/tapout';
|
|
1164
|
-
|
|
1165
|
-
addDisposeCallback(element, () => {
|
|
1166
|
-
// runs when the node is cleaned or removed
|
|
1167
|
-
});
|
|
1168
|
-
|
|
1169
|
-
cleanNode(element); // run dispose callbacks, clear data, recurse into children
|
|
1170
|
-
removeNode(element); // clean + remove from parent
|
|
1171
|
-
```
|
|
1172
|
-
|
|
1173
|
-
---
|
|
1174
|
-
|
|
1175
|
-
## Configuration
|
|
1176
|
-
|
|
1177
|
-
The `options` object controls global behavior:
|
|
1178
|
-
|
|
1179
|
-
```typescript
|
|
1180
|
-
import { options } from '@thomas-siegfried/tapout';
|
|
1181
|
-
```
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
| Option | Default | Description |
|
|
1185
|
-
| ------------------------------ | ---------------------- | ------------------------------------------------------------------- |
|
|
1186
|
-
| `deferUpdates` | `false` | Auto-apply `deferred` extender to all new observables and computeds |
|
|
1187
|
-
| `onError` | `null` | Global error handler for deferred task failures |
|
|
1188
|
-
| `viewModelFactory` | `(ctor) => new ctor()` | Factory for component VM instantiation (useful for DI) |
|
|
1189
|
-
| `customElementDisplayContents` | `true` | Apply `display: contents` to custom elements |
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
---
|
|
1193
|
-
|
|
1194
|
-
## Acknowledgments
|
|
1195
|
-
|
|
1196
|
-
Tapout is built on the ideas and philosophy of [KnockoutJS](https://knockoutjs.com/), created by **Steve Sanderson**. Knockout pioneered the pattern of simple, explicit, dependency-tracked reactivity with declarative DOM bindings, and that core vision remains at the heart of Tapout.
|
|
1197
|
-
|
|
1198
|
-
Tapout's interpolation markup, filter syntax, namespaced bindings, and preprocessor infrastructure are directly inspired by [Knockout.Punches](https://mbest.github.io/knockout.punches/), created by **Michael Best**.
|
|
1199
|
-
|
|
1200
|
-
Thank you to both authors for their foundational work.
|
|
1201
|
-
|
|
1202
|
-
---
|
|
1203
|
-
|
|
1204
|
-
## License
|
|
1205
|
-
|
|
1
|
+
# Tapout
|
|
2
|
+
|
|
3
|
+
A modern ESM reactivity and templating library, spiritually inspired by [KnockoutJS](https://knockoutjs.com/). Not a drop-in replacement — same philosophy, modern implementation.
|
|
4
|
+
|
|
5
|
+
Tapout provides dependency-tracked observables, computed values, declarative DOM bindings, a component system, and TC39 Stage 3 decorators — all in a lightweight, explicit architecture built on TypeScript.
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Installation](#installation)
|
|
10
|
+
- [Quick Start](#quick-start)
|
|
11
|
+
- [Core Reactivity](#core-reactivity)
|
|
12
|
+
- [Observable](#observable)
|
|
13
|
+
- [ObservableArray](#observablearray)
|
|
14
|
+
- [Computed](#computed)
|
|
15
|
+
- [PureComputed](#purecomputed)
|
|
16
|
+
- [Effects](#effects)
|
|
17
|
+
- [Subscriptions](#subscriptions)
|
|
18
|
+
- [Events](#events)
|
|
19
|
+
- [Extenders](#extenders)
|
|
20
|
+
- [Decorators](#decorators)
|
|
21
|
+
- [@reactive](#reactive)
|
|
22
|
+
- [@reactiveArray](#reactivearray)
|
|
23
|
+
- [@computed](#computed-decorator)
|
|
24
|
+
- [getObservable / replaceObservable](#getobservable--replaceobservable)
|
|
25
|
+
- [Binding System](#binding-system)
|
|
26
|
+
- [Applying Bindings](#applying-bindings)
|
|
27
|
+
- [Binding Context](#binding-context)
|
|
28
|
+
- [Built-in Bindings](#built-in-bindings)
|
|
29
|
+
- [Virtual Elements](#virtual-elements)
|
|
30
|
+
- [Interpolation Markup](#interpolation-markup)
|
|
31
|
+
- [Attribute Interpolation](#attribute-interpolation)
|
|
32
|
+
- [Namespaced Bindings](#namespaced-bindings)
|
|
33
|
+
- [Filters](#filters)
|
|
34
|
+
- [Components](#components)
|
|
35
|
+
- [Registration](#registration)
|
|
36
|
+
- [The @component Decorator](#the-component-decorator)
|
|
37
|
+
- [Templates](#templates)
|
|
38
|
+
- [View Models](#view-models)
|
|
39
|
+
- [Params and Wiring](#params-and-wiring)
|
|
40
|
+
- [Lifecycle](#lifecycle)
|
|
41
|
+
- [Slots](#slots)
|
|
42
|
+
- [Custom Elements](#custom-elements)
|
|
43
|
+
- [Utilities](#utilities)
|
|
44
|
+
- [Configuration](#configuration)
|
|
45
|
+
- [Acknowledgments](#acknowledgments)
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm install @thomas-siegfried/tapout
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Tapout is ESM-only and requires a modern bundler or runtime that supports ES modules.
|
|
56
|
+
|
|
57
|
+
### Entry Points
|
|
58
|
+
|
|
59
|
+
Tapout provides two entry points for tree shaking:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// Full library — reactivity + DOM bindings + templates + components
|
|
63
|
+
import { Observable, applyBindings, renderTemplate } from '@thomas-siegfried/tapout';
|
|
64
|
+
|
|
65
|
+
// Core only — reactivity primitives, no DOM dependencies
|
|
66
|
+
import { Observable, Computed, Event, effect } from '@thomas-siegfried/tapout/core';
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Entry Point | Includes |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `@thomas-siegfried/tapout` | Everything: core reactivity, DOM bindings, templates, components, side-effect registrations |
|
|
72
|
+
| `@thomas-siegfried/tapout/core` | Observables, computed, subscriptions, events, effects, extenders, decorators, disposable, task scheduler, pure utilities |
|
|
73
|
+
|
|
74
|
+
Use `@thomas-siegfried/tapout/core` when you only need the reactive data layer — for example, in a Node.js backend, a shared library, or a project with its own rendering layer.
|
|
75
|
+
|
|
76
|
+
## Quick Start
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { Observable, Computed, applyBindings } from '@thomas-siegfried/tapout';
|
|
80
|
+
|
|
81
|
+
class ViewModel {
|
|
82
|
+
firstName = new Observable('Jane');
|
|
83
|
+
lastName = new Observable('Doe');
|
|
84
|
+
fullName = new Computed(() => `${this.firstName.get()} ${this.lastName.get()}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
applyBindings(new ViewModel(), document.body);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<p>First: <input data-bind="textInput: firstName" /></p>
|
|
92
|
+
<p>Last: <input data-bind="textInput: lastName" /></p>
|
|
93
|
+
<h2 data-bind="text: fullName"></h2>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Or with decorators:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { reactive, computed, applyBindings } from '@thomas-siegfried/tapout';
|
|
100
|
+
|
|
101
|
+
class ViewModel {
|
|
102
|
+
@reactive accessor firstName = 'Jane';
|
|
103
|
+
@reactive accessor lastName = 'Doe';
|
|
104
|
+
|
|
105
|
+
@computed get fullName() {
|
|
106
|
+
return `${this.firstName} ${this.lastName}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
applyBindings(new ViewModel(), document.body);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Core Reactivity
|
|
116
|
+
|
|
117
|
+
### Observable
|
|
118
|
+
|
|
119
|
+
A mutable reactive value. Reading inside a computed or effect automatically registers a dependency.
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
import { Observable } from '@thomas-siegfried/tapout';
|
|
123
|
+
|
|
124
|
+
const count = new Observable(0);
|
|
125
|
+
|
|
126
|
+
count.get(); // 0 — reads the value (tracks dependency)
|
|
127
|
+
count.peek(); // 0 — reads without tracking
|
|
128
|
+
count.set(5); // writes a new value, notifies subscribers
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Setting the same primitive value again does not trigger notifications. For objects and arrays, every `set()` notifies regardless of reference equality. You can customize this behavior:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
count.equalityComparer = (a, b) => a === b;
|
|
135
|
+
count.equalityComparer = undefined; // always notify
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Force a notification after in-place mutation:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
const data = new Observable({ name: 'Alice' });
|
|
142
|
+
data.peek().name = 'Bob';
|
|
143
|
+
data.valueHasMutated();
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Type guard: `isObservable(value)`.
|
|
147
|
+
|
|
148
|
+
### ObservableArray
|
|
149
|
+
|
|
150
|
+
An `Observable<T[]>` with array-like methods that automatically notify on mutation.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import { ObservableArray } from '@thomas-siegfried/tapout';
|
|
154
|
+
|
|
155
|
+
const items = new ObservableArray(['a', 'b', 'c']);
|
|
156
|
+
|
|
157
|
+
items.push('d');
|
|
158
|
+
items.remove('b');
|
|
159
|
+
items.splice(0, 1, 'x');
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Mutators** (all trigger change notifications): `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse`, `remove`, `removeAll`, `destroy`, `destroyAll`, `replace`.
|
|
163
|
+
|
|
164
|
+
**Readers** (all track dependencies): `length`, `indexOf`, `slice`, `sorted`, `reversed`, `map`, `filter`, `find`, `findIndex`, `some`, `every`, `forEach`, `reduce`, `includes`, `at`, `join`, `flat`, `flatMap`, `entries`, `keys`, `values`, `[Symbol.iterator]`.
|
|
165
|
+
|
|
166
|
+
**Array change tracking** — subscribe to the `arrayChange` event to receive fine-grained diffs:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
items.subscribe(changes => {
|
|
170
|
+
for (const change of changes) {
|
|
171
|
+
console.log(change.status, change.value, change.index);
|
|
172
|
+
}
|
|
173
|
+
}, 'arrayChange');
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Each change has `status` (`'added'`, `'deleted'`, or `'retained'`), `value`, `index`, and optionally `moved`.
|
|
177
|
+
|
|
178
|
+
Type guard: `isObservableArray(value)`.
|
|
179
|
+
|
|
180
|
+
### Computed
|
|
181
|
+
|
|
182
|
+
A derived value that automatically re-evaluates when its dependencies change.
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
import { Observable, Computed } from '@thomas-siegfried/tapout';
|
|
186
|
+
|
|
187
|
+
const width = new Observable(10);
|
|
188
|
+
const height = new Observable(20);
|
|
189
|
+
const area = new Computed(() => width.get() * height.get());
|
|
190
|
+
|
|
191
|
+
area.get(); // 200
|
|
192
|
+
width.set(5);
|
|
193
|
+
area.get(); // 100
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Computed values only notify their subscribers when the result actually changes.
|
|
197
|
+
|
|
198
|
+
**Writable computed** — provide a `write` function to enable two-way usage:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
const first = new Observable('Jane');
|
|
202
|
+
const last = new Observable('Doe');
|
|
203
|
+
|
|
204
|
+
const full = new Computed({
|
|
205
|
+
read: () => `${first.get()} ${last.get()}`,
|
|
206
|
+
write: (value: string) => {
|
|
207
|
+
const [f, l] = value.split(' ');
|
|
208
|
+
first.set(f);
|
|
209
|
+
last.set(l);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
full.set('John Smith'); // updates first and last
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Deferred evaluation** — delay the first evaluation until the value is actually needed:
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
const lazy = new Computed({
|
|
220
|
+
read: () => expensiveCalc(),
|
|
221
|
+
deferEvaluation: true,
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Inspection:**
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
area.getDependenciesCount(); // 2
|
|
229
|
+
area.getDependencies(); // [width, height]
|
|
230
|
+
area.isActive(); // true if it has dependencies
|
|
231
|
+
area.hasWriteFunction; // boolean
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Call `area.dispose()` to stop tracking and release all dependency subscriptions.
|
|
235
|
+
|
|
236
|
+
Type guard: `isComputed(value)`.
|
|
237
|
+
|
|
238
|
+
### PureComputed
|
|
239
|
+
|
|
240
|
+
A memory-optimized computed that *sleeps* when it has no subscribers, releasing its dependency subscriptions. It *wakes* automatically when someone subscribes.
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
import { PureComputed } from '@thomas-siegfried/tapout';
|
|
244
|
+
|
|
245
|
+
const label = new PureComputed(() => `Count: ${count.get()}`);
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Use `PureComputed` for values that are only needed intermittently (e.g., computed values backing a UI that may or may not be in the DOM). It is the default backing for `@computed` decorators.
|
|
249
|
+
|
|
250
|
+
Type guards: `isPureComputed(value)`, `isComputed(value)`.
|
|
251
|
+
|
|
252
|
+
### Effects
|
|
253
|
+
|
|
254
|
+
Side-effect helpers that track reactive dependencies and re-run when they change.
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
import { Observable, effect, observe } from '@thomas-siegfried/tapout';
|
|
258
|
+
|
|
259
|
+
const name = new Observable('Alice');
|
|
260
|
+
|
|
261
|
+
// effect: runs immediately, then again on each change
|
|
262
|
+
const handle = effect(
|
|
263
|
+
() => name.get(),
|
|
264
|
+
val => console.log(`Hello, ${val}!`),
|
|
265
|
+
);
|
|
266
|
+
// logs: "Hello, Alice!"
|
|
267
|
+
|
|
268
|
+
name.set('Bob');
|
|
269
|
+
// logs: "Hello, Bob!"
|
|
270
|
+
|
|
271
|
+
handle.dispose(); // stop watching
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
// observe: does NOT run immediately — only fires on subsequent changes
|
|
276
|
+
const handle = observe(
|
|
277
|
+
() => name.get(),
|
|
278
|
+
val => console.log(`Changed to ${val}`),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
name.set('Charlie');
|
|
282
|
+
// logs: "Changed to Charlie"
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Both functions return an `EffectHandle`:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
interface EffectHandle {
|
|
289
|
+
dispose(): void;
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Calling `dispose()` stops the effect, releasing the internal computed and its dependency subscriptions.
|
|
294
|
+
|
|
295
|
+
### Subscriptions
|
|
296
|
+
|
|
297
|
+
Every `Subscribable` (Observable, Computed, etc.) supports subscriptions:
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
const sub = count.subscribe(value => {
|
|
301
|
+
console.log('New value:', value);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
sub.closed; // false
|
|
305
|
+
sub.dispose(); // unsubscribe
|
|
306
|
+
sub.closed; // true
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**Events** — the second argument selects the event channel:
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
| Event | Fires when |
|
|
313
|
+
| -------------------- | ----------------------------------------------- |
|
|
314
|
+
| `'change'` (default) | After the value changes |
|
|
315
|
+
| `'beforeChange'` | Before a new value is written |
|
|
316
|
+
| `'spectate'` | On every write, regardless of equality |
|
|
317
|
+
| `'dirty'` | Synchronously when a deferred value is dirtied |
|
|
318
|
+
| `'awake'` | When a PureComputed wakes up |
|
|
319
|
+
| `'asleep'` | When a PureComputed goes to sleep |
|
|
320
|
+
| `'arrayChange'` | Fine-grained array diffs (ObservableArray only) |
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
```typescript
|
|
324
|
+
count.subscribe(oldVal => console.log('Was:', oldVal), 'beforeChange');
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**Auto-dispose with DOM nodes:**
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
sub.disposeWhenNodeIsRemoved(someElement);
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
The subscription is automatically disposed when the DOM node is cleaned or removed.
|
|
334
|
+
|
|
335
|
+
### Events
|
|
336
|
+
|
|
337
|
+
Events are stateless, hot signals — like observables that don't hold a value. They emit values on demand, and only active subscribers receive them.
|
|
338
|
+
|
|
339
|
+
An `Event` has a **two-sided** design (similar to Deferred/Promise): the owner calls `emit()`, and consumers receive the read-only `subscribable` side.
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
import { Event } from '@thomas-siegfried/tapout';
|
|
343
|
+
|
|
344
|
+
class SaveEvent {
|
|
345
|
+
constructor(public id: number, public success: boolean) {}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
class MyService {
|
|
349
|
+
private _onSave = new Event<SaveEvent>();
|
|
350
|
+
readonly onSave = this._onSave.subscribable; // hand this out
|
|
351
|
+
|
|
352
|
+
save(id: number) {
|
|
353
|
+
// ... perform save ...
|
|
354
|
+
this._onSave.emit(new SaveEvent(id, true));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const service = new MyService();
|
|
359
|
+
const sub = service.onSave.subscribe(e => {
|
|
360
|
+
console.log(`Saved item ${e.id}`);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
service.save(42); // logs: Saved item 42
|
|
364
|
+
sub.dispose(); // stop listening
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**Type-filtered subscriptions** — use `.on(Type)` to subscribe to only matching event types via `instanceof`:
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
class DeleteEvent {
|
|
371
|
+
constructor(public id: number) {}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const event = new Event<SaveEvent | DeleteEvent>();
|
|
375
|
+
|
|
376
|
+
event.subscribable.on(SaveEvent).subscribe(e => {
|
|
377
|
+
console.log(`Save: ${e.id}`); // only SaveEvent instances
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
event.subscribable.on(DeleteEvent).subscribe(e => {
|
|
381
|
+
console.log(`Delete: ${e.id}`); // only DeleteEvent instances
|
|
382
|
+
});
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
**Aggregate events** — roll up multiple event sources into one, like DOM event bubbling through a tree:
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
import { Event, AggregateEvent } from '@thomas-siegfried/tapout';
|
|
389
|
+
|
|
390
|
+
class ItemChangedEvent {
|
|
391
|
+
constructor(public itemId: number) {}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
class GrandChild {
|
|
395
|
+
private _onChange = new Event<ItemChangedEvent>();
|
|
396
|
+
readonly events = new AggregateEvent<ItemChangedEvent>();
|
|
397
|
+
|
|
398
|
+
constructor() {
|
|
399
|
+
this.events.pipe(this._onChange.subscribable);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
change(id: number) { this._onChange.emit(new ItemChangedEvent(id)); }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
class Child {
|
|
406
|
+
private _onSave = new Event<SaveEvent>();
|
|
407
|
+
private _onDelete = new Event<DeleteEvent>();
|
|
408
|
+
readonly grandChild = new GrandChild();
|
|
409
|
+
|
|
410
|
+
// Roll up own events + grandchild's aggregate
|
|
411
|
+
readonly events = new AggregateEvent<SaveEvent | DeleteEvent | ItemChangedEvent>();
|
|
412
|
+
|
|
413
|
+
constructor() {
|
|
414
|
+
this.events.pipe(
|
|
415
|
+
this._onSave.subscribable,
|
|
416
|
+
this._onDelete.subscribable,
|
|
417
|
+
this.grandChild.events.subscribable,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const child = new Child();
|
|
423
|
+
|
|
424
|
+
// Subscribe to everything in the tree
|
|
425
|
+
child.events.subscribable.subscribe(e => {
|
|
426
|
+
console.log('Something happened:', e);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// Or filter to a specific type
|
|
430
|
+
child.events.subscribable.on(SaveEvent).subscribe(e => {
|
|
431
|
+
console.log(`Save: ${e.id}`);
|
|
432
|
+
});
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
`pipe()` accepts multiple sources in a single call and can be called again to add sources later. It returns an array of `EventSubscription` objects for individual disposal.
|
|
436
|
+
|
|
437
|
+
Events are **hot** — no replay, no current value. If nobody is listening when `emit()` is called, the value is lost. Calling `dispose()` on an Event tears down all subscriptions (including piped sources on an `AggregateEvent`).
|
|
438
|
+
|
|
439
|
+
**DisposableGroup** — a utility for centralized subscription cleanup. Works with both `Subscription` (from observables) and `EventSubscription` (from events):
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
import { DisposableGroup, Observable, Event } from '@thomas-siegfried/tapout';
|
|
443
|
+
|
|
444
|
+
class MyComponent {
|
|
445
|
+
private _subs = new DisposableGroup();
|
|
446
|
+
readonly count = new Observable(0);
|
|
447
|
+
|
|
448
|
+
constructor(events: EventSubscribable<SaveEvent>) {
|
|
449
|
+
this._subs.add(events.subscribe(e => this.onSave(e)));
|
|
450
|
+
this._subs.add(this.count.subscribe(v => console.log('Count:', v)));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
dispose() {
|
|
454
|
+
this._subs.dispose(); // cleans up all subscriptions at once
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
`add()` returns the disposable, so you can still hold a reference for early individual disposal. Any items added after the group is already disposed are immediately disposed.
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## Extenders
|
|
464
|
+
|
|
465
|
+
Extenders modify the notification behavior of any subscribable.
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
const search = new Observable('');
|
|
469
|
+
|
|
470
|
+
// Debounce: wait until changes stop for 300ms before notifying
|
|
471
|
+
search.extend({ rateLimit: { timeout: 300, method: 'notifyWhenChangesStop' } });
|
|
472
|
+
|
|
473
|
+
// Throttle: notify at most once every 200ms (default method)
|
|
474
|
+
search.extend({ rateLimit: 200 });
|
|
475
|
+
|
|
476
|
+
// Always notify, even when value hasn't changed
|
|
477
|
+
search.extend({ notify: 'always' });
|
|
478
|
+
|
|
479
|
+
// Defer notifications to the microtask queue
|
|
480
|
+
search.extend({ deferred: true });
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
`extend()` returns the same instance, so calls can be chained.
|
|
484
|
+
|
|
485
|
+
### Custom Extenders
|
|
486
|
+
|
|
487
|
+
```typescript
|
|
488
|
+
import { registerExtender } from '@thomas-siegfried/tapout';
|
|
489
|
+
|
|
490
|
+
registerExtender('logChanges', (target, label) => {
|
|
491
|
+
target.subscribe(val => console.log(`[${label}]`, val));
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const obs = new Observable(0);
|
|
495
|
+
obs.extend({ logChanges: 'myObs' });
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### Global Deferred Updates
|
|
499
|
+
|
|
500
|
+
Enable deferred notifications for all new observables and computeds:
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
import { options } from '@thomas-siegfried/tapout';
|
|
504
|
+
|
|
505
|
+
options.deferUpdates = true;
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
When enabled, multiple synchronous writes are batched into a single notification on the next microtask.
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
## Decorators
|
|
513
|
+
|
|
514
|
+
Tapout provides TC39 Stage 3 class decorators for a cleaner syntax. These work with TypeScript 5.0+ and the `--experimentalDecorators` flag is **not** needed — these are native decorators.
|
|
515
|
+
|
|
516
|
+
### @reactive
|
|
517
|
+
|
|
518
|
+
Turns a class accessor into an `Observable`-backed property.
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
import { reactive } from '@thomas-siegfried/tapout';
|
|
522
|
+
|
|
523
|
+
class Settings {
|
|
524
|
+
@reactive accessor theme = 'dark';
|
|
525
|
+
@reactive accessor fontSize = 14;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const s = new Settings();
|
|
529
|
+
s.theme; // 'dark' (reads the Observable, tracks dependency)
|
|
530
|
+
s.theme = 'light'; // writes through to the Observable, notifies subscribers
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Pass extender options:
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
@reactive({ notify: 'always' }) accessor tag = '';
|
|
537
|
+
@reactive({ deferred: true }) accessor query = '';
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
### @reactiveArray
|
|
541
|
+
|
|
542
|
+
Turns a class accessor into an `ObservableArray`-backed property.
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
import { reactiveArray } from '@thomas-siegfried/tapout';
|
|
546
|
+
|
|
547
|
+
class TodoList {
|
|
548
|
+
@reactiveArray accessor items: string[] = [];
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const list = new TodoList();
|
|
552
|
+
(list.items as any).push('Buy milk'); // mutates the ObservableArray
|
|
553
|
+
list.items.length; // 1
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
The getter returns the `ObservableArray` instance directly. The setter calls `set()` on the underlying array.
|
|
557
|
+
|
|
558
|
+
### @computed (decorator)
|
|
559
|
+
|
|
560
|
+
Works on getters, getter+setter pairs, and methods.
|
|
561
|
+
|
|
562
|
+
```typescript
|
|
563
|
+
import { reactive, computed } from '@thomas-siegfried/tapout';
|
|
564
|
+
|
|
565
|
+
class FullName {
|
|
566
|
+
@reactive accessor first = 'John';
|
|
567
|
+
@reactive accessor last = 'Doe';
|
|
568
|
+
|
|
569
|
+
@computed get full() {
|
|
570
|
+
return `${this.first} ${this.last}`;
|
|
571
|
+
}
|
|
572
|
+
set full(v: string) {
|
|
573
|
+
[this.first, this.last] = v.split(' ');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
@computed nameLength() {
|
|
577
|
+
return this.first.length + this.last.length;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
- Getters create a read-only `Computed` per instance (with `deferEvaluation: true`)
|
|
583
|
+
- Getter+setter pairs create a writable `Computed`
|
|
584
|
+
- Methods become computed-backed — calling returns the computed value
|
|
585
|
+
|
|
586
|
+
### getObservable / replaceObservable
|
|
587
|
+
|
|
588
|
+
Retrieve or replace the underlying reactive primitive for a decorated property:
|
|
589
|
+
|
|
590
|
+
```typescript
|
|
591
|
+
import { getObservable, replaceObservable } from '@thomas-siegfried/tapout';
|
|
592
|
+
|
|
593
|
+
const obs = getObservable(instance, 'theme');
|
|
594
|
+
// returns the Observable, ObservableArray, or Computed backing the property
|
|
595
|
+
|
|
596
|
+
replaceObservable(instance, 'theme', anotherObservable);
|
|
597
|
+
// swap the backing observable (used internally by wireParams for two-way binding)
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
## Binding System
|
|
603
|
+
|
|
604
|
+
### Applying Bindings
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
import { applyBindings } from '@thomas-siegfried/tapout';
|
|
608
|
+
|
|
609
|
+
const vm = new ViewModel();
|
|
610
|
+
applyBindings(vm, document.getElementById('app'));
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
| Function | Purpose |
|
|
615
|
+
| ------------------------------------------ | --------------------------------------- |
|
|
616
|
+
| `applyBindings(vm, rootNode)` | Bind a view model to a DOM subtree |
|
|
617
|
+
| `applyBindingsToDescendants(vm, rootNode)` | Bind only the children, not the root |
|
|
618
|
+
| `applyBindingsToNode(node, bindings, vm?)` | Bind specific bindings to a single node |
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
### Binding Context
|
|
622
|
+
|
|
623
|
+
Inside bindings, the following context properties are available:
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
| Property | Description |
|
|
627
|
+
| ------------------------- | --------------------------------------------- |
|
|
628
|
+
| `$data` | The current data item |
|
|
629
|
+
| `$rawData` | The raw (possibly observable) data |
|
|
630
|
+
| `$root` | The root view model |
|
|
631
|
+
| `$parent` | The parent's `$data` |
|
|
632
|
+
| `$parentContext` | The parent binding context |
|
|
633
|
+
| `$parents` | Array of all ancestor `$data` values |
|
|
634
|
+
| `$index` | Current item index (inside `foreach`) |
|
|
635
|
+
| `$component` | The component view model (inside components) |
|
|
636
|
+
| `$componentTemplateNodes` | Original child nodes of the component element |
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
Inspect the context of a DOM node programmatically:
|
|
640
|
+
|
|
641
|
+
```typescript
|
|
642
|
+
import { contextFor, dataFor } from '@thomas-siegfried/tapout';
|
|
643
|
+
|
|
644
|
+
const ctx = contextFor(someElement); // BindingContext
|
|
645
|
+
const data = dataFor(someElement); // $data
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
### Built-in Bindings
|
|
649
|
+
|
|
650
|
+
#### Display
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
| Binding | Example | Description |
|
|
654
|
+
| --------- | ------------------------------- | ----------------------- |
|
|
655
|
+
| `text` | `data-bind="text: message"` | Sets text content |
|
|
656
|
+
| `html` | `data-bind="html: richContent"` | Sets innerHTML |
|
|
657
|
+
| `visible` | `data-bind="visible: isShown"` | Toggles `display: none` |
|
|
658
|
+
| `hidden` | `data-bind="hidden: isShown"` | Inverse of `visible` |
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
#### Attributes
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
| Binding | Examplee | Description |
|
|
665
|
+
| ------- | --------------------------------------------- | ------------------------------- |
|
|
666
|
+
| `attr` | `data-bind="attr: { href: url, title: tip }"` | Sets/removes attributes |
|
|
667
|
+
| `css` | `data-bind="css: { active: isActive }"` | Toggles CSS classes |
|
|
668
|
+
| `class` | `data-bind="class: className"` | Sets the class attribute string |
|
|
669
|
+
| `style` | `data-bind="style: { color: textColor }"` | Sets inline styles |
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
#### Form State
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
| Binding | Example | Description |
|
|
676
|
+
| ------------ | --------------------------------- | ---------------------------------------- |
|
|
677
|
+
| `enable` | `data-bind="enable: canSubmit"` | Enables/disables the element |
|
|
678
|
+
| `disable` | `data-bind="disable: isReadOnly"` | Inverse of `enable` |
|
|
679
|
+
| `uniqueName` | `data-bind="uniqueName: true"` | Auto-generates a unique `name` attribute |
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
#### Events
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
| Binding | Example | Description |
|
|
686
|
+
| ---------- | ------------------------------------------- | -------------------------------------- |
|
|
687
|
+
| `event` | `data-bind="event: { mouseover: onHover }"` | Binds one or more event handlers |
|
|
688
|
+
| `click` | `data-bind="click: onClick"` | Shorthand for click events |
|
|
689
|
+
| `submit` | `data-bind="submit: onSubmit"` | Form submit handler (prevents default) |
|
|
690
|
+
| `keydown` | `data-bind="keydown.enter: onEnter"` | Key-filtered keydown (see below) |
|
|
691
|
+
| `keyup` | `data-bind="keyup.space: onSpace"` | Key-filtered keyup (see below) |
|
|
692
|
+
| `enter` | `data-bind="enter: onEnter"` | Shorthand for `keydown.enter` |
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
Event handlers receive `$data` as the first argument and the DOM event as the second. Return `true` from a handler to allow default browser behavior. Control bubbling with `eventNameBubble: false`:
|
|
696
|
+
|
|
697
|
+
```html
|
|
698
|
+
<button data-bind="click: onClick, clickBubble: false">Click</button>
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
##### Key Event Bindings
|
|
702
|
+
|
|
703
|
+
The `keydown` and `keyup` bindings use the namespaced binding system to filter by key and modifier. The part after the first dot is the key name, and additional dots add modifier requirements:
|
|
704
|
+
|
|
705
|
+
```html
|
|
706
|
+
<!-- Single key -->
|
|
707
|
+
<input data-bind="keydown.enter: handleEnter">
|
|
708
|
+
<input data-bind="keydown.esc: handleEsc">
|
|
709
|
+
<input data-bind="keyup.tab: handleTab">
|
|
710
|
+
|
|
711
|
+
<!-- Key + modifiers -->
|
|
712
|
+
<input data-bind="keydown.enter.ctrl: handleCtrlEnter">
|
|
713
|
+
<input data-bind="keydown.s.ctrl: handleSave">
|
|
714
|
+
<input data-bind="keydown.enter.ctrl.shift: handleCtrlShiftEnter">
|
|
715
|
+
|
|
716
|
+
<!-- Plain (no filter, fires on any keydown) -->
|
|
717
|
+
<input data-bind="keydown: handleAnyKey">
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
**Key aliases** — use these shorthand names or any raw `KeyboardEvent.key` value (e.g. `a`, `s`, `F1`):
|
|
721
|
+
|
|
722
|
+
| Alias | `KeyboardEvent.key` |
|
|
723
|
+
| ----------- | -------------------- |
|
|
724
|
+
| `enter` | `Enter` |
|
|
725
|
+
| `tab` | `Tab` |
|
|
726
|
+
| `esc` | `Escape` |
|
|
727
|
+
| `escape` | `Escape` |
|
|
728
|
+
| `space` | ` ` (space) |
|
|
729
|
+
| `delete` | `Delete` |
|
|
730
|
+
| `backspace` | `Backspace` |
|
|
731
|
+
| `up` | `ArrowUp` |
|
|
732
|
+
| `down` | `ArrowDown` |
|
|
733
|
+
| `left` | `ArrowLeft` |
|
|
734
|
+
| `right` | `ArrowRight` |
|
|
735
|
+
|
|
736
|
+
**Modifiers** — append any combination of `ctrl`, `alt`, `shift`, `meta`:
|
|
737
|
+
|
|
738
|
+
```html
|
|
739
|
+
<input data-bind="keydown.enter.alt: onAltEnter">
|
|
740
|
+
<input data-bind="keydown.s.ctrl.shift: onCtrlShiftS">
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
#### Form Values (Two-Way)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
| Binding | Example | Description |
|
|
747
|
+
| ----------------- | ------------------------------------- | ------------------------------------------- |
|
|
748
|
+
| `value` | `data-bind="value: selectedItem"` | Two-way value binding (updates on `change`) |
|
|
749
|
+
| `textInput` | `data-bind="textInput: query"` | Live text binding (updates on `input`) |
|
|
750
|
+
| `checked` | `data-bind="checked: isAgreed"` | Checkbox/radio two-way binding |
|
|
751
|
+
| `checkedValue` | `data-bind="checkedValue: itemId"` | Sets the value sent when checked |
|
|
752
|
+
| `hasFocus` | `data-bind="hasFocus: isFocused"` | Two-way focus binding |
|
|
753
|
+
| `selectedOptions` | `data-bind="selectedOptions: chosen"` | Multi-select binding |
|
|
754
|
+
| `options` | `data-bind="options: items"` | Populates a `<select>` from an array |
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
The `options` binding supports additional parameters:
|
|
758
|
+
|
|
759
|
+
```html
|
|
760
|
+
<select data-bind="options: people,
|
|
761
|
+
optionsText: 'name',
|
|
762
|
+
optionsValue: 'id',
|
|
763
|
+
optionsCaption: 'Choose...',
|
|
764
|
+
value: selectedPersonId">
|
|
765
|
+
</select>
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
The `checked` binding supports array mode for checkbox groups:
|
|
769
|
+
|
|
770
|
+
```html
|
|
771
|
+
<input type="checkbox" data-bind="checked: selectedColors, checkedValue: 'red'" />
|
|
772
|
+
<input type="checkbox" data-bind="checked: selectedColors, checkedValue: 'blue'" />
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
#### Control Flow
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
| Binding | Example | Description |
|
|
779
|
+
| ---------- | ---------------------------------------- | ------------------------------------------ |
|
|
780
|
+
| `if` | `data-bind="if: isLoggedIn"` | Conditionally renders content |
|
|
781
|
+
| `ifnot` | `data-bind="ifnot: isEmpty"` | Inverse conditional |
|
|
782
|
+
| `with` | `data-bind="with: selectedItem"` | Creates a child context; hides when falsy |
|
|
783
|
+
| `using` | `data-bind="using: config"` | Like `with` but always renders |
|
|
784
|
+
| `let` | `data-bind="let: { x: computedVal }"` | Extends context with additional properties |
|
|
785
|
+
| `foreach` | `data-bind="foreach: items"` | Iterates over an array |
|
|
786
|
+
| `template` | `data-bind="template: { name: 'tmpl' }"` | Renders a named or anonymous template |
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
The `foreach` binding creates a child context for each item with `$data`, `$index`, and `$parent`:
|
|
790
|
+
|
|
791
|
+
```html
|
|
792
|
+
<ul data-bind="foreach: people">
|
|
793
|
+
<li>
|
|
794
|
+
<span data-bind="text: $index"></span>:
|
|
795
|
+
<span data-bind="text: name"></span>
|
|
796
|
+
</li>
|
|
797
|
+
</ul>
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
Use `as` to alias the item:
|
|
801
|
+
|
|
802
|
+
```html
|
|
803
|
+
<ul data-bind="foreach: { data: people, as: 'person' }">
|
|
804
|
+
<li data-bind="text: person.name"></li>
|
|
805
|
+
</ul>
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
#### Dialog
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
| Binding | Example | Description |
|
|
812
|
+
| ------- | --------------------------------- | --------------------------------------------- |
|
|
813
|
+
| `modal` | `data-bind="modal: isDialogOpen"` | Calls `showModal()`/`close()` on a `<dialog>` |
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
### Virtual Elements
|
|
817
|
+
|
|
818
|
+
Use HTML comments for bindings that don't need a wrapper element:
|
|
819
|
+
|
|
820
|
+
```html
|
|
821
|
+
<!-- tap if: showSection -->
|
|
822
|
+
<p>Conditional content</p>
|
|
823
|
+
<!-- /tap -->
|
|
824
|
+
|
|
825
|
+
<!-- tap foreach: items -->
|
|
826
|
+
<span data-bind="text: $data"></span>
|
|
827
|
+
<!-- /tap -->
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
The following bindings support virtual elements: `text`, `html`, `if`, `ifnot`, `with`, `let`, `using`, `foreach`, `template`, `component`, `slot`.
|
|
831
|
+
|
|
832
|
+
### Configuration
|
|
833
|
+
|
|
834
|
+
Tapout has several opt-in features that enhance the binding system. Enable them declaratively via `options`, or all at once with `enableAll()`:
|
|
835
|
+
|
|
836
|
+
```typescript
|
|
837
|
+
import { options } from '@thomas-siegfried/tapout';
|
|
838
|
+
|
|
839
|
+
options.interpolation = true; // {{ }} text interpolation
|
|
840
|
+
options.attributeInterpolation = true; // {{ }} inside HTML attributes
|
|
841
|
+
options.namespacedBindings = true; // Dot-notation: attr.href, keydown.enter
|
|
842
|
+
options.filters = true; // Pipe filters on all bindings
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
Features are activated automatically on the first `applyBindings` call. Or enable everything at once:
|
|
846
|
+
|
|
847
|
+
```typescript
|
|
848
|
+
import { enableAll } from '@thomas-siegfried/tapout';
|
|
849
|
+
enableAll();
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
The individual enable functions (`enableInterpolationMarkup`, `enableNamespacedBindings`, etc.) still work for granular control.
|
|
853
|
+
|
|
854
|
+
The `options` object also includes runtime settings:
|
|
855
|
+
|
|
856
|
+
| Option | Default | Description |
|
|
857
|
+
| ---------------------------- | ------------------- | ------------------------------------------------ |
|
|
858
|
+
| `deferUpdates` | `false` | Batch writes into microtask notifications |
|
|
859
|
+
| `onError` | `null` | Global error handler for bindings/computeds |
|
|
860
|
+
| `viewModelFactory` | `(ctor) => new ctor()` | Factory for component view model instantiation |
|
|
861
|
+
| `customElementDisplayContents` | `true` | Custom elements use `display: contents` |
|
|
862
|
+
|
|
863
|
+
### Interpolation Markup
|
|
864
|
+
|
|
865
|
+
Enable text interpolation for a more template-like syntax (or set `options.interpolation = true`):
|
|
866
|
+
|
|
867
|
+
```typescript
|
|
868
|
+
import { enableInterpolationMarkup } from '@thomas-siegfried/tapout';
|
|
869
|
+
enableInterpolationMarkup();
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
Then use `{{ }}` in your HTML:
|
|
873
|
+
|
|
874
|
+
```html
|
|
875
|
+
<span>Hello, {{ name }}!</span>
|
|
876
|
+
|
|
877
|
+
<!-- Raw HTML (triple braces) -->
|
|
878
|
+
<div>{{{ richContent }}}</div>
|
|
879
|
+
|
|
880
|
+
<!-- Block control flow -->
|
|
881
|
+
{{# if isLoggedIn }}
|
|
882
|
+
<p>Welcome back, {{ username }}!</p>
|
|
883
|
+
{{/ if }}
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
### Attribute Interpolation
|
|
887
|
+
|
|
888
|
+
Enable interpolation inside HTML attributes (or set `options.attributeInterpolation = true`):
|
|
889
|
+
|
|
890
|
+
```typescript
|
|
891
|
+
import { enableAttributeInterpolationMarkup } from '@thomas-siegfried/tapout';
|
|
892
|
+
enableAttributeInterpolationMarkup();
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
```html
|
|
896
|
+
<a href="{{ baseUrl }}/profile/{{ userId }}">Profile</a>
|
|
897
|
+
<img title="Photo of {{ name }}" />
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
### Namespaced Bindings
|
|
901
|
+
|
|
902
|
+
Enable shorthand dot-notation for attribute-like bindings (or set `options.namespacedBindings = true`):
|
|
903
|
+
|
|
904
|
+
```typescript
|
|
905
|
+
import { enableNamespacedBindings } from '@thomas-siegfried/tapout';
|
|
906
|
+
enableNamespacedBindings();
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
```html
|
|
910
|
+
<a data-bind="attr.href: profileUrl, css.active: isSelected">Link</a>
|
|
911
|
+
<div data-bind="style.color: textColor, event.click: onClick"></div>
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
### Filters
|
|
915
|
+
|
|
916
|
+
Add pipe-style filters to binding values. Set `options.filters = true` to enable on all bindings, or use `enableTextFilter` for specific ones:
|
|
917
|
+
|
|
918
|
+
```typescript
|
|
919
|
+
import { enableTextFilter } from '@thomas-siegfried/tapout';
|
|
920
|
+
|
|
921
|
+
enableTextFilter('text');
|
|
922
|
+
enableTextFilter('html');
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
```html
|
|
926
|
+
<span data-bind="text: name | uppercase"></span>
|
|
927
|
+
<span data-bind="text: bio | default:'No bio provided'"></span>
|
|
928
|
+
<span data-bind="text: data | json"></span>
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
**Built-in filters:** `uppercase`, `lowercase`, `default`, `json`.
|
|
932
|
+
|
|
933
|
+
**Custom filters:**
|
|
934
|
+
|
|
935
|
+
```typescript
|
|
936
|
+
import { filters } from '@thomas-siegfried/tapout';
|
|
937
|
+
|
|
938
|
+
filters['truncate'] = (value: string, maxLength: number) => {
|
|
939
|
+
return value.length > maxLength ? value.slice(0, maxLength) + '...' : value;
|
|
940
|
+
};
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
```html
|
|
944
|
+
<span data-bind="text: description | truncate:100"></span>
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
Filters work in both `data-bind` attributes and `{{ }}` interpolation.
|
|
948
|
+
|
|
949
|
+
---
|
|
950
|
+
|
|
951
|
+
## Components
|
|
952
|
+
|
|
953
|
+
### Registration
|
|
954
|
+
|
|
955
|
+
```typescript
|
|
956
|
+
import { components } from '@thomas-siegfried/tapout';
|
|
957
|
+
|
|
958
|
+
components.register('user-card', {
|
|
959
|
+
template: '<div><span data-bind="text: name"></span></div>',
|
|
960
|
+
viewModel: UserCardViewModel,
|
|
961
|
+
synchronous: true,
|
|
962
|
+
});
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
Use them in HTML:
|
|
966
|
+
|
|
967
|
+
```html
|
|
968
|
+
<user-card params="name: userName"></user-card>
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
Or with the `component` binding:
|
|
972
|
+
|
|
973
|
+
```html
|
|
974
|
+
<div data-bind="component: { name: 'user-card', params: { name: userName } }"></div>
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
### The @component Decorator
|
|
978
|
+
|
|
979
|
+
Register components declaratively:
|
|
980
|
+
|
|
981
|
+
```typescript
|
|
982
|
+
import { component, reactive } from '@thomas-siegfried/tapout';
|
|
983
|
+
|
|
984
|
+
@component('user-card', '<div><span data-bind="text: name"></span></div>')
|
|
985
|
+
class UserCard {
|
|
986
|
+
@reactive accessor name = '';
|
|
987
|
+
}
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
Or with options:
|
|
991
|
+
|
|
992
|
+
```typescript
|
|
993
|
+
@component({ tag: 'user-card', template: '<div>...</div>', synchronous: true })
|
|
994
|
+
class UserCard { ... }
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
Retrieve the tag from a class or instance:
|
|
998
|
+
|
|
999
|
+
```typescript
|
|
1000
|
+
import { getComponentTag } from '@thomas-siegfried/tapout';
|
|
1001
|
+
getComponentTag(UserCard); // 'user-card'
|
|
1002
|
+
getComponentTag(new UserCard()); // 'user-card'
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
### Templates
|
|
1006
|
+
|
|
1007
|
+
Templates can be provided as:
|
|
1008
|
+
|
|
1009
|
+
- A **string** of HTML
|
|
1010
|
+
- A **DOM element** or `DocumentFragment`
|
|
1011
|
+
- An **array of nodes**
|
|
1012
|
+
- `{ element: 'template-id' }` — references a `<template>`, `<script>`, or other element by ID
|
|
1013
|
+
|
|
1014
|
+
### View Models
|
|
1015
|
+
|
|
1016
|
+
The `viewModel` config accepts:
|
|
1017
|
+
|
|
1018
|
+
- A **class constructor** — a new instance is created per component
|
|
1019
|
+
- `{ createViewModel(params, componentInfo) }` — factory function
|
|
1020
|
+
- `{ instance: existingObject }` — shared singleton instance
|
|
1021
|
+
|
|
1022
|
+
### Params and Wiring
|
|
1023
|
+
|
|
1024
|
+
Pass parameters to components via the `params` attribute:
|
|
1025
|
+
|
|
1026
|
+
```html
|
|
1027
|
+
<user-card params="name: userName, age: 30"></user-card>
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
Inside the component, `wireParams` connects parameters to `@reactive` properties:
|
|
1031
|
+
|
|
1032
|
+
- **Plain values** are assigned directly
|
|
1033
|
+
- **Computed params** create a one-way subscription (parent updates flow to child)
|
|
1034
|
+
- **Observable params** with `$`-prefix enable two-way binding by sharing the backing observable
|
|
1035
|
+
|
|
1036
|
+
```html
|
|
1037
|
+
<!-- Two-way: child and parent share the same Observable -->
|
|
1038
|
+
<user-card params="name: $sharedName"></user-card>
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
The `$`-prefix syntax passes the parent's raw `Observable` so both sides read and write the same value.
|
|
1042
|
+
|
|
1043
|
+
You can also call `wireParams` manually:
|
|
1044
|
+
|
|
1045
|
+
```typescript
|
|
1046
|
+
import { wireParams } from '@thomas-siegfried/tapout';
|
|
1047
|
+
|
|
1048
|
+
const result = wireParams(viewModelInstance, params);
|
|
1049
|
+
// result.subscriptions — array of subscriptions to dispose later
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
### Lifecycle
|
|
1053
|
+
|
|
1054
|
+
Component view models can implement these lifecycle methods:
|
|
1055
|
+
|
|
1056
|
+
1. `**onInit()**` — Called after VM creation and param wiring, before template binding. Use for initial setup.
|
|
1057
|
+
2. `**onDescendantsComplete(node)**` — Called after all descendant bindings are complete. Good for DOM measurement or third-party widget initialization.
|
|
1058
|
+
3. `**dispose()**` — Called when the component's DOM node is cleaned. Use for cleanup.
|
|
1059
|
+
|
|
1060
|
+
```typescript
|
|
1061
|
+
@component('my-widget', '<div>...</div>')
|
|
1062
|
+
class MyWidget {
|
|
1063
|
+
@reactive accessor data = '';
|
|
1064
|
+
|
|
1065
|
+
onInit() {
|
|
1066
|
+
// fetch initial data, set up state
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
onDescendantsComplete(node: Node) {
|
|
1070
|
+
// DOM is ready, descendants are bound
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
dispose() {
|
|
1074
|
+
// clean up subscriptions, timers, etc.
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
```
|
|
1078
|
+
|
|
1079
|
+
Execution order: `onInit` → template binding → `onDescendantsComplete` → (on cleanup) → `dispose`.
|
|
1080
|
+
|
|
1081
|
+
### Slots
|
|
1082
|
+
|
|
1083
|
+
Project content from a component's consumer into its template using the `slot` binding.
|
|
1084
|
+
|
|
1085
|
+
**Component template:**
|
|
1086
|
+
|
|
1087
|
+
```html
|
|
1088
|
+
<div class="card">
|
|
1089
|
+
<header data-bind="slot: 'header'">Default Header</header>
|
|
1090
|
+
<div data-bind="slot: ''">Default body content</div>
|
|
1091
|
+
<footer data-bind="slot: 'footer'">Default Footer</footer>
|
|
1092
|
+
</div>
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
**Usage:**
|
|
1096
|
+
|
|
1097
|
+
```html
|
|
1098
|
+
<my-card>
|
|
1099
|
+
<h2 slot="header">Custom Title</h2>
|
|
1100
|
+
<p>This goes into the default slot.</p>
|
|
1101
|
+
<small slot="footer">Custom footer</small>
|
|
1102
|
+
</my-card>
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
Slotted content binds in the **parent** context, not the component's context. Fallback content inside the slot binding is shown when no matching content is provided.
|
|
1106
|
+
|
|
1107
|
+
### Custom Elements
|
|
1108
|
+
|
|
1109
|
+
Any registered component is automatically detected as a custom element. Tapout applies `display: contents` to custom elements by default so they don't affect layout. Disable this with:
|
|
1110
|
+
|
|
1111
|
+
```typescript
|
|
1112
|
+
import { options } from '@thomas-siegfried/tapout';
|
|
1113
|
+
options.customElementDisplayContents = false;
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
---
|
|
1117
|
+
|
|
1118
|
+
## Utilities
|
|
1119
|
+
|
|
1120
|
+
### toJS / toJSON
|
|
1121
|
+
|
|
1122
|
+
Deep-unwrap all observables in an object graph:
|
|
1123
|
+
|
|
1124
|
+
```typescript
|
|
1125
|
+
import { toJS, toJSON } from '@thomas-siegfried/tapout';
|
|
1126
|
+
|
|
1127
|
+
const plain = toJS(viewModel); // plain JS object, no observables
|
|
1128
|
+
const json = toJSON(viewModel, null, 2); // JSON string
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
Handles nested objects, arrays, `ObservableArray`, `Date`, `RegExp`, and circular references.
|
|
1132
|
+
|
|
1133
|
+
### when
|
|
1134
|
+
|
|
1135
|
+
Wait for a reactive condition to become truthy:
|
|
1136
|
+
|
|
1137
|
+
```typescript
|
|
1138
|
+
import { when } from '@thomas-siegfried/tapout';
|
|
1139
|
+
|
|
1140
|
+
// With callback — returns a disposable Subscription
|
|
1141
|
+
const sub = when(() => items.length > 0, () => {
|
|
1142
|
+
console.log('Items loaded!');
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
// Without callback — returns a Promise
|
|
1146
|
+
await when(() => isReady.get());
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
`when` is one-shot: once the condition is met, the subscription is automatically disposed.
|
|
1150
|
+
|
|
1151
|
+
### unwrapObservable / peekObservable
|
|
1152
|
+
|
|
1153
|
+
```typescript
|
|
1154
|
+
import { unwrapObservable, peekObservable } from '@thomas-siegfried/tapout';
|
|
1155
|
+
|
|
1156
|
+
unwrapObservable(obs); // recursively unwraps observables (up to 10 levels)
|
|
1157
|
+
peekObservable(obs); // same but uses peek() — no dependency tracking
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
### DOM Utilities
|
|
1161
|
+
|
|
1162
|
+
```typescript
|
|
1163
|
+
import { cleanNode, removeNode, addDisposeCallback } from '@thomas-siegfried/tapout';
|
|
1164
|
+
|
|
1165
|
+
addDisposeCallback(element, () => {
|
|
1166
|
+
// runs when the node is cleaned or removed
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
cleanNode(element); // run dispose callbacks, clear data, recurse into children
|
|
1170
|
+
removeNode(element); // clean + remove from parent
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
---
|
|
1174
|
+
|
|
1175
|
+
## Configuration
|
|
1176
|
+
|
|
1177
|
+
The `options` object controls global behavior:
|
|
1178
|
+
|
|
1179
|
+
```typescript
|
|
1180
|
+
import { options } from '@thomas-siegfried/tapout';
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
| Option | Default | Description |
|
|
1185
|
+
| ------------------------------ | ---------------------- | ------------------------------------------------------------------- |
|
|
1186
|
+
| `deferUpdates` | `false` | Auto-apply `deferred` extender to all new observables and computeds |
|
|
1187
|
+
| `onError` | `null` | Global error handler for deferred task failures |
|
|
1188
|
+
| `viewModelFactory` | `(ctor) => new ctor()` | Factory for component VM instantiation (useful for DI) |
|
|
1189
|
+
| `customElementDisplayContents` | `true` | Apply `display: contents` to custom elements |
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
---
|
|
1193
|
+
|
|
1194
|
+
## Acknowledgments
|
|
1195
|
+
|
|
1196
|
+
Tapout is built on the ideas and philosophy of [KnockoutJS](https://knockoutjs.com/), created by **Steve Sanderson**. Knockout pioneered the pattern of simple, explicit, dependency-tracked reactivity with declarative DOM bindings, and that core vision remains at the heart of Tapout.
|
|
1197
|
+
|
|
1198
|
+
Tapout's interpolation markup, filter syntax, namespaced bindings, and preprocessor infrastructure are directly inspired by [Knockout.Punches](https://mbest.github.io/knockout.punches/), created by **Michael Best**.
|
|
1199
|
+
|
|
1200
|
+
Thank you to both authors for their foundational work.
|
|
1201
|
+
|
|
1202
|
+
---
|
|
1203
|
+
|
|
1204
|
+
## License
|
|
1205
|
+
|
|
1206
1206
|
MIT
|