@thomas-siegfried/tapout 0.0.1

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.
Files changed (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1206 -0
  3. package/dist/applyBindings.d.ts +10 -0
  4. package/dist/applyBindings.d.ts.map +1 -0
  5. package/dist/applyBindings.js +261 -0
  6. package/dist/applyBindings.js.map +1 -0
  7. package/dist/arrayToDomMapping.d.ts +14 -0
  8. package/dist/arrayToDomMapping.d.ts.map +1 -0
  9. package/dist/arrayToDomMapping.js +219 -0
  10. package/dist/arrayToDomMapping.js.map +1 -0
  11. package/dist/attributeInterpolationMarkup.d.ts +6 -0
  12. package/dist/attributeInterpolationMarkup.d.ts.map +1 -0
  13. package/dist/attributeInterpolationMarkup.js +70 -0
  14. package/dist/attributeInterpolationMarkup.js.map +1 -0
  15. package/dist/bindingContext.d.ts +38 -0
  16. package/dist/bindingContext.d.ts.map +1 -0
  17. package/dist/bindingContext.js +138 -0
  18. package/dist/bindingContext.js.map +1 -0
  19. package/dist/bindingEvent.d.ts +33 -0
  20. package/dist/bindingEvent.d.ts.map +1 -0
  21. package/dist/bindingEvent.js +144 -0
  22. package/dist/bindingEvent.js.map +1 -0
  23. package/dist/bindingProvider.d.ts +38 -0
  24. package/dist/bindingProvider.d.ts.map +1 -0
  25. package/dist/bindingProvider.js +145 -0
  26. package/dist/bindingProvider.js.map +1 -0
  27. package/dist/bindings.d.ts +2 -0
  28. package/dist/bindings.d.ts.map +1 -0
  29. package/dist/bindings.js +862 -0
  30. package/dist/bindings.js.map +1 -0
  31. package/dist/compareArrays.d.ts +13 -0
  32. package/dist/compareArrays.d.ts.map +1 -0
  33. package/dist/compareArrays.js +91 -0
  34. package/dist/compareArrays.js.map +1 -0
  35. package/dist/componentBinding.d.ts +4 -0
  36. package/dist/componentBinding.d.ts.map +1 -0
  37. package/dist/componentBinding.js +240 -0
  38. package/dist/componentBinding.js.map +1 -0
  39. package/dist/componentDecorator.d.ts +22 -0
  40. package/dist/componentDecorator.d.ts.map +1 -0
  41. package/dist/componentDecorator.js +32 -0
  42. package/dist/componentDecorator.js.map +1 -0
  43. package/dist/components.d.ts +34 -0
  44. package/dist/components.d.ts.map +1 -0
  45. package/dist/components.js +256 -0
  46. package/dist/components.js.map +1 -0
  47. package/dist/computed.d.ts +54 -0
  48. package/dist/computed.d.ts.map +1 -0
  49. package/dist/computed.js +376 -0
  50. package/dist/computed.js.map +1 -0
  51. package/dist/configure.d.ts +4 -0
  52. package/dist/configure.d.ts.map +1 -0
  53. package/dist/configure.js +48 -0
  54. package/dist/configure.js.map +1 -0
  55. package/dist/controlFlowBindings.d.ts +2 -0
  56. package/dist/controlFlowBindings.d.ts.map +1 -0
  57. package/dist/controlFlowBindings.js +151 -0
  58. package/dist/controlFlowBindings.js.map +1 -0
  59. package/dist/core.d.ts +29 -0
  60. package/dist/core.d.ts.map +1 -0
  61. package/dist/core.js +27 -0
  62. package/dist/core.js.map +1 -0
  63. package/dist/decorators.d.ts +40 -0
  64. package/dist/decorators.d.ts.map +1 -0
  65. package/dist/decorators.js +276 -0
  66. package/dist/decorators.js.map +1 -0
  67. package/dist/dependencyDetection.d.ts +21 -0
  68. package/dist/dependencyDetection.d.ts.map +1 -0
  69. package/dist/dependencyDetection.js +53 -0
  70. package/dist/dependencyDetection.js.map +1 -0
  71. package/dist/disposable.d.ts +12 -0
  72. package/dist/disposable.d.ts.map +1 -0
  73. package/dist/disposable.js +29 -0
  74. package/dist/disposable.js.map +1 -0
  75. package/dist/domData.d.ts +6 -0
  76. package/dist/domData.d.ts.map +1 -0
  77. package/dist/domData.js +40 -0
  78. package/dist/domData.js.map +1 -0
  79. package/dist/domNodeDisposal.d.ts +7 -0
  80. package/dist/domNodeDisposal.d.ts.map +1 -0
  81. package/dist/domNodeDisposal.js +78 -0
  82. package/dist/domNodeDisposal.js.map +1 -0
  83. package/dist/effects.d.ts +13 -0
  84. package/dist/effects.d.ts.map +1 -0
  85. package/dist/effects.js +24 -0
  86. package/dist/effects.js.map +1 -0
  87. package/dist/event.d.ts +38 -0
  88. package/dist/event.d.ts.map +1 -0
  89. package/dist/event.js +138 -0
  90. package/dist/event.js.map +1 -0
  91. package/dist/expressionRewriting.d.ts +22 -0
  92. package/dist/expressionRewriting.d.ts.map +1 -0
  93. package/dist/expressionRewriting.js +169 -0
  94. package/dist/expressionRewriting.js.map +1 -0
  95. package/dist/extenders.d.ts +17 -0
  96. package/dist/extenders.d.ts.map +1 -0
  97. package/dist/extenders.js +75 -0
  98. package/dist/extenders.js.map +1 -0
  99. package/dist/filters.d.ts +5 -0
  100. package/dist/filters.d.ts.map +1 -0
  101. package/dist/filters.js +81 -0
  102. package/dist/filters.js.map +1 -0
  103. package/dist/index.d.ts +45 -0
  104. package/dist/index.d.ts.map +1 -0
  105. package/dist/index.js +35 -0
  106. package/dist/index.js.map +1 -0
  107. package/dist/interpolationMarkup.d.ts +6 -0
  108. package/dist/interpolationMarkup.d.ts.map +1 -0
  109. package/dist/interpolationMarkup.js +110 -0
  110. package/dist/interpolationMarkup.js.map +1 -0
  111. package/dist/memoization.d.ts +7 -0
  112. package/dist/memoization.d.ts.map +1 -0
  113. package/dist/memoization.js +59 -0
  114. package/dist/memoization.js.map +1 -0
  115. package/dist/namespacedBindings.d.ts +7 -0
  116. package/dist/namespacedBindings.d.ts.map +1 -0
  117. package/dist/namespacedBindings.js +81 -0
  118. package/dist/namespacedBindings.js.map +1 -0
  119. package/dist/observable.d.ts +13 -0
  120. package/dist/observable.d.ts.map +1 -0
  121. package/dist/observable.js +39 -0
  122. package/dist/observable.js.map +1 -0
  123. package/dist/observableArray.d.ts +58 -0
  124. package/dist/observableArray.d.ts.map +1 -0
  125. package/dist/observableArray.js +328 -0
  126. package/dist/observableArray.js.map +1 -0
  127. package/dist/options.d.ts +14 -0
  128. package/dist/options.d.ts.map +1 -0
  129. package/dist/options.js +12 -0
  130. package/dist/options.js.map +1 -0
  131. package/dist/selectExtensions.d.ts +4 -0
  132. package/dist/selectExtensions.d.ts.map +1 -0
  133. package/dist/selectExtensions.js +67 -0
  134. package/dist/selectExtensions.js.map +1 -0
  135. package/dist/slotBinding.d.ts +2 -0
  136. package/dist/slotBinding.d.ts.map +1 -0
  137. package/dist/slotBinding.js +63 -0
  138. package/dist/slotBinding.js.map +1 -0
  139. package/dist/subscribable.d.ts +57 -0
  140. package/dist/subscribable.d.ts.map +1 -0
  141. package/dist/subscribable.js +211 -0
  142. package/dist/subscribable.js.map +1 -0
  143. package/dist/tasks.d.ts +8 -0
  144. package/dist/tasks.d.ts.map +1 -0
  145. package/dist/tasks.js +68 -0
  146. package/dist/tasks.js.map +1 -0
  147. package/dist/templateEngine.d.ts +37 -0
  148. package/dist/templateEngine.d.ts.map +1 -0
  149. package/dist/templateEngine.js +60 -0
  150. package/dist/templateEngine.js.map +1 -0
  151. package/dist/templateRendering.d.ts +8 -0
  152. package/dist/templateRendering.d.ts.map +1 -0
  153. package/dist/templateRendering.js +322 -0
  154. package/dist/templateRendering.js.map +1 -0
  155. package/dist/templateRewriting.d.ts +7 -0
  156. package/dist/templateRewriting.d.ts.map +1 -0
  157. package/dist/templateRewriting.js +63 -0
  158. package/dist/templateRewriting.js.map +1 -0
  159. package/dist/templateSources.d.ts +19 -0
  160. package/dist/templateSources.d.ts.map +1 -0
  161. package/dist/templateSources.js +110 -0
  162. package/dist/templateSources.js.map +1 -0
  163. package/dist/utils.d.ts +8 -0
  164. package/dist/utils.d.ts.map +1 -0
  165. package/dist/utils.js +100 -0
  166. package/dist/utils.js.map +1 -0
  167. package/dist/utilsDom.d.ts +8 -0
  168. package/dist/utilsDom.d.ts.map +1 -0
  169. package/dist/utilsDom.js +81 -0
  170. package/dist/utilsDom.js.map +1 -0
  171. package/dist/virtualElements.d.ts +12 -0
  172. package/dist/virtualElements.d.ts.map +1 -0
  173. package/dist/virtualElements.js +141 -0
  174. package/dist/virtualElements.js.map +1 -0
  175. package/dist/wireParams.d.ts +6 -0
  176. package/dist/wireParams.d.ts.map +1 -0
  177. package/dist/wireParams.js +39 -0
  178. package/dist/wireParams.js.map +1 -0
  179. package/package.json +55 -0
  180. package/src/applyBindings.ts +372 -0
  181. package/src/arrayToDomMapping.ts +300 -0
  182. package/src/attributeInterpolationMarkup.ts +91 -0
  183. package/src/bindingContext.ts +207 -0
  184. package/src/bindingEvent.ts +198 -0
  185. package/src/bindingProvider.ts +260 -0
  186. package/src/bindings.ts +963 -0
  187. package/src/compareArrays.ts +122 -0
  188. package/src/componentBinding.ts +318 -0
  189. package/src/componentDecorator.ts +62 -0
  190. package/src/components.ts +367 -0
  191. package/src/computed.ts +439 -0
  192. package/src/configure.ts +52 -0
  193. package/src/controlFlowBindings.ts +206 -0
  194. package/src/core.ts +60 -0
  195. package/src/decorators.ts +407 -0
  196. package/src/dependencyDetection.ts +76 -0
  197. package/src/disposable.ts +36 -0
  198. package/src/domData.ts +42 -0
  199. package/src/domNodeDisposal.ts +94 -0
  200. package/src/effects.ts +29 -0
  201. package/src/event.ts +173 -0
  202. package/src/expressionRewriting.ts +219 -0
  203. package/src/extenders.ts +102 -0
  204. package/src/filters.ts +91 -0
  205. package/src/index.ts +150 -0
  206. package/src/interpolationMarkup.ts +130 -0
  207. package/src/memoization.ts +71 -0
  208. package/src/namespacedBindings.ts +132 -0
  209. package/src/observable.ts +48 -0
  210. package/src/observableArray.ts +397 -0
  211. package/src/options.ts +25 -0
  212. package/src/selectExtensions.ts +68 -0
  213. package/src/slotBinding.ts +84 -0
  214. package/src/subscribable.ts +266 -0
  215. package/src/tasks.ts +79 -0
  216. package/src/templateEngine.ts +108 -0
  217. package/src/templateRendering.ts +399 -0
  218. package/src/templateRewriting.ts +94 -0
  219. package/src/templateSources.ts +134 -0
  220. package/src/utils.ts +123 -0
  221. package/src/utilsDom.ts +87 -0
  222. package/src/virtualElements.ts +153 -0
  223. package/src/wireParams.ts +49 -0
package/README.md ADDED
@@ -0,0 +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
+
1206
+ MIT