@thomas-siegfried/tapout 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/decorator-config.md +220 -0
- package/dist/wireParams.d.ts.map +1 -1
- package/dist/wireParams.js +27 -4
- package/dist/wireParams.js.map +1 -1
- package/llms.txt +360 -0
- package/package.json +4 -2
- package/src/wireParams.ts +24 -4
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ A modern ESM reactivity and templating library, spiritually inspired by [Knockou
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
+
For detailed TypeScript decorator setup and compatibility notes, see [`decorator-config.md`](./decorator-config.md).
|
|
8
|
+
|
|
7
9
|
## Table of Contents
|
|
8
10
|
|
|
9
11
|
- [Installation](#installation)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Vite Decorator Setup Guide
|
|
2
|
+
|
|
3
|
+
Four tested configurations for using decorators with Vite 7/8 and tapout.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Option A: Babel + TC39 Stage 3 Decorators
|
|
8
|
+
|
|
9
|
+
Uses the `accessor` keyword. Babel transpiles TC39 decorators into `_applyDecs` helper calls.
|
|
10
|
+
|
|
11
|
+
### Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -D vite-plugin-babel @babel/plugin-proposal-decorators @babel/plugin-transform-typescript
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### `vite.config.ts`
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { defineConfig } from 'vite';
|
|
21
|
+
import babel from 'vite-plugin-babel';
|
|
22
|
+
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
resolve: {
|
|
25
|
+
preserveSymlinks: true, // only needed for npm link scenarios
|
|
26
|
+
},
|
|
27
|
+
oxc: false as any, // disable Vite 8's default Oxc transpiler
|
|
28
|
+
plugins: [
|
|
29
|
+
babel({
|
|
30
|
+
filter: /\.[jt]sx?$/, // must include .ts/.tsx — default only handles .js/.jsx
|
|
31
|
+
babelConfig: {
|
|
32
|
+
plugins: [
|
|
33
|
+
['@babel/plugin-proposal-decorators', { version: '2023-11' }],
|
|
34
|
+
['@babel/plugin-transform-typescript', { allowDeclareFields: true }],
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### `tsconfig.json`
|
|
43
|
+
|
|
44
|
+
Do **not** add `experimentalDecorators`. Keep `useDefineForClassFields: true` (the default).
|
|
45
|
+
|
|
46
|
+
### Source syntax
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
@reactive accessor currentRoute: string = 'dashboard';
|
|
50
|
+
@reactiveArray accessor items: Item[] = [];
|
|
51
|
+
@computed get fullName(): string { return ... }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Option B: Babel + Experimental (Legacy) Decorators
|
|
57
|
+
|
|
58
|
+
No `accessor` keyword. Babel transpiles legacy decorators. Requires `@babel/plugin-transform-class-properties` with `loose: true` to prevent Babel from creating own data properties that shadow the decorator's getter/setter.
|
|
59
|
+
|
|
60
|
+
### Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm install -D vite-plugin-babel @babel/plugin-proposal-decorators @babel/plugin-transform-class-properties @babel/plugin-transform-typescript
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### `vite.config.ts`
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { defineConfig } from 'vite';
|
|
70
|
+
import babel from 'vite-plugin-babel';
|
|
71
|
+
|
|
72
|
+
export default defineConfig({
|
|
73
|
+
resolve: {
|
|
74
|
+
preserveSymlinks: true,
|
|
75
|
+
},
|
|
76
|
+
oxc: false as any,
|
|
77
|
+
plugins: [
|
|
78
|
+
babel({
|
|
79
|
+
filter: /\.[jt]sx?$/,
|
|
80
|
+
babelConfig: {
|
|
81
|
+
plugins: [
|
|
82
|
+
['@babel/plugin-proposal-decorators', { version: 'legacy' }],
|
|
83
|
+
['@babel/plugin-transform-class-properties', { loose: true }],
|
|
84
|
+
['@babel/plugin-transform-typescript', { allowDeclareFields: true }],
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### `tsconfig.json`
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"compilerOptions": {
|
|
97
|
+
"experimentalDecorators": true,
|
|
98
|
+
"useDefineForClassFields": false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Source syntax
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
@reactive currentRoute: string = 'dashboard';
|
|
107
|
+
@reactiveArray items: Item[] = [];
|
|
108
|
+
@computed get fullName(): string { return ... }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Babel-specific note
|
|
112
|
+
|
|
113
|
+
Babel passes a `descriptor` with an `initializer` property to legacy field decorators — this is a Babel convention, not a standard. tapout handles this to prevent Babel's class property transform from shadowing the decorator's getter/setter with a plain data property.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Option C: Oxc (Vite 8 built-in) + Experimental (Legacy) Decorators
|
|
118
|
+
|
|
119
|
+
No Babel needed — Vite 8's built-in Oxc transpiler handles legacy decorators natively. Zero extra dependencies.
|
|
120
|
+
|
|
121
|
+
### Install
|
|
122
|
+
|
|
123
|
+
No additional packages needed beyond Vite itself.
|
|
124
|
+
|
|
125
|
+
### `vite.config.ts`
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import { defineConfig } from 'vite';
|
|
129
|
+
|
|
130
|
+
export default defineConfig({
|
|
131
|
+
resolve: {
|
|
132
|
+
preserveSymlinks: true,
|
|
133
|
+
},
|
|
134
|
+
oxc: {
|
|
135
|
+
decorator: {
|
|
136
|
+
legacy: true,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `tsconfig.json`
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"compilerOptions": {
|
|
147
|
+
"experimentalDecorators": true,
|
|
148
|
+
"useDefineForClassFields": false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Source syntax
|
|
154
|
+
|
|
155
|
+
Same as Option B (no `accessor` keyword).
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Option D: esbuild (Vite 7 built-in) + Experimental (Legacy) Decorators
|
|
160
|
+
|
|
161
|
+
No Babel or Oxc needed — Vite 7's built-in esbuild transpiler handles legacy decorators natively by reading `tsconfig.json`. Zero extra dependencies.
|
|
162
|
+
|
|
163
|
+
### Install
|
|
164
|
+
|
|
165
|
+
No additional packages needed beyond Vite itself.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npm install -D vite@~7.3.1
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### `vite.config.ts`
|
|
172
|
+
|
|
173
|
+
No `vite.config.ts` is required. esbuild reads `experimentalDecorators` and `useDefineForClassFields` directly from `tsconfig.json` — no explicit esbuild config needed.
|
|
174
|
+
|
|
175
|
+
### `tsconfig.json`
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"compilerOptions": {
|
|
180
|
+
"experimentalDecorators": true,
|
|
181
|
+
"useDefineForClassFields": false
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`erasableSyntaxOnly` (added in TypeScript 5.9) must **not** be enabled — it rejects decorator syntax as non-erasable.
|
|
187
|
+
|
|
188
|
+
### Source syntax
|
|
189
|
+
|
|
190
|
+
Same as Option B (no `accessor` keyword).
|
|
191
|
+
|
|
192
|
+
### esbuild-specific notes
|
|
193
|
+
|
|
194
|
+
- esbuild does **not** support `emitDecoratorMetadata`. tapout does not use `reflect-metadata`, so this is a non-issue.
|
|
195
|
+
- esbuild does **not** pass a `descriptor` with an `initializer` to legacy field decorators (unlike Babel). tapout's legacy path handles both cases — with and without the Babel-style `initializer`.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Quick Reference
|
|
200
|
+
|
|
201
|
+
| | Option A | Option B | Option C | Option D |
|
|
202
|
+
|---|---|---|---|---|
|
|
203
|
+
| Vite version | 8 | 8 | 8 | 7 |
|
|
204
|
+
| Spec | TC39 Stage 3 | Experimental/Legacy | Experimental/Legacy | Experimental/Legacy |
|
|
205
|
+
| Transpiler | Babel | Babel | Oxc (built-in) | esbuild (built-in) |
|
|
206
|
+
| Extra deps | 2 packages | 3 packages | None | None |
|
|
207
|
+
| `accessor` keyword | Yes | No | No | No |
|
|
208
|
+
| `experimentalDecorators` | No | Yes | Yes | Yes |
|
|
209
|
+
| `useDefineForClassFields` | `true` | `false` | `false` | `false` |
|
|
210
|
+
| `emitDecoratorMetadata` | N/A | Supported | Supported | Not supported |
|
|
211
|
+
|
|
212
|
+
## Common Gotchas
|
|
213
|
+
|
|
214
|
+
- **`oxc: false as any`** — required for Options A/B to disable Vite 8's default Oxc transpiler so Babel takes over. The `as any` is needed because Vite 8's types don't expose this option.
|
|
215
|
+
- **`filter: /\.[jt]sx?$/`** — critical for Babel options. Without it, `.ts` files bypass Babel and hit the default transpiler.
|
|
216
|
+
- **`preserveSymlinks: true`** (Vite 8) / **`resolve.dedupe`** (Vite 7) — only needed when using `npm link` for local package development.
|
|
217
|
+
- **Plugin order matters** — decorators must come before class-properties, which must come before TypeScript transform (Options A/B only).
|
|
218
|
+
- **Vite caches pre-bundled deps** — after changing a linked package, delete `node_modules/.vite` and restart the dev server.
|
|
219
|
+
- **`erasableSyntaxOnly: true`** — if present in `tsconfig.json`, must be removed when enabling `experimentalDecorators`. TypeScript 5.9 introduced this flag to enforce type-only syntax, but decorator emit is runtime code.
|
|
220
|
+
- **esbuild reads tsconfig automatically** (Option D) — no `vite.config.ts` is needed at all. esbuild picks up `experimentalDecorators` and `useDefineForClassFields` from the project's `tsconfig.json`.
|
package/dist/wireParams.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wireParams.d.ts","sourceRoot":"","sources":["../src/wireParams.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"wireParams.d.ts","sourceRoot":"","sources":["../src/wireParams.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAkBtD,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,YAAY,CAAC,OAAO,CAAC,EAAE,CAAC;CACxC;AAED,wBAAgB,UAAU,CACxB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,OAAO,CAAC,EAAE,IAAI,GACb,gBAAgB,CAuClB"}
|
package/dist/wireParams.js
CHANGED
|
@@ -4,14 +4,27 @@ import { isSubscribable } from './subscribable.js';
|
|
|
4
4
|
import { getObservable, replaceObservable } from './decorators.js';
|
|
5
5
|
import { addDisposeCallback } from './domNodeDisposal.js';
|
|
6
6
|
const SKIP_KEYS = new Set(['$raw']);
|
|
7
|
+
function getWritableChildTarget(instance, key) {
|
|
8
|
+
const decorated = getObservable(instance, key);
|
|
9
|
+
if (decorated instanceof Observable)
|
|
10
|
+
return decorated;
|
|
11
|
+
if (decorated instanceof Computed && decorated.hasWriteFunction)
|
|
12
|
+
return decorated;
|
|
13
|
+
const directValue = instance[key];
|
|
14
|
+
if (directValue instanceof Observable)
|
|
15
|
+
return directValue;
|
|
16
|
+
if (directValue instanceof Computed && directValue.hasWriteFunction)
|
|
17
|
+
return directValue;
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
7
20
|
export function wireParams(instance, params, element) {
|
|
8
21
|
const subscriptions = [];
|
|
9
22
|
for (const key of Object.keys(params)) {
|
|
10
23
|
if (SKIP_KEYS.has(key))
|
|
11
24
|
continue;
|
|
12
25
|
const paramValue = params[key];
|
|
13
|
-
const
|
|
14
|
-
const childIsReactive =
|
|
26
|
+
const childTarget = getWritableChildTarget(instance, key);
|
|
27
|
+
const childIsReactive = childTarget !== undefined && isSubscribable(childTarget);
|
|
15
28
|
if (paramValue instanceof Observable) {
|
|
16
29
|
if (childIsReactive) {
|
|
17
30
|
replaceObservable(instance, key, paramValue);
|
|
@@ -21,9 +34,19 @@ export function wireParams(instance, params, element) {
|
|
|
21
34
|
}
|
|
22
35
|
}
|
|
23
36
|
else if (paramValue instanceof Computed) {
|
|
24
|
-
|
|
37
|
+
if (childTarget) {
|
|
38
|
+
childTarget.set(paramValue.get());
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
instance[key] = paramValue.get();
|
|
42
|
+
}
|
|
25
43
|
const sub = paramValue.subscribe((newValue) => {
|
|
26
|
-
|
|
44
|
+
if (childTarget) {
|
|
45
|
+
childTarget.set(newValue);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
instance[key] = newValue;
|
|
49
|
+
}
|
|
27
50
|
});
|
|
28
51
|
subscriptions.push(sub);
|
|
29
52
|
if (element) {
|
package/dist/wireParams.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wireParams.js","sourceRoot":"","sources":["../src/wireParams.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAE1D,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"wireParams.js","sourceRoot":"","sources":["../src/wireParams.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAE1D,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;AAEpC,SAAS,sBAAsB,CAAC,QAAgB,EAAE,GAAW;IAC3D,MAAM,SAAS,GAAG,aAAa,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IAC/C,IAAI,SAAS,YAAY,UAAU;QAAE,OAAO,SAAS,CAAC;IACtD,IAAI,SAAS,YAAY,QAAQ,IAAI,SAAS,CAAC,gBAAgB;QAAE,OAAO,SAAS,CAAC;IAElF,MAAM,WAAW,GAAI,QAAoC,CAAC,GAAG,CAAC,CAAC;IAC/D,IAAI,WAAW,YAAY,UAAU;QAAE,OAAO,WAAW,CAAC;IAC1D,IAAI,WAAW,YAAY,QAAQ,IAAI,WAAW,CAAC,gBAAgB;QAAE,OAAO,WAAW,CAAC;IAExF,OAAO,SAAS,CAAC;AACnB,CAAC;AAMD,MAAM,UAAU,UAAU,CACxB,QAAgB,EAChB,MAA+B,EAC/B,OAAc;IAEd,MAAM,aAAa,GAA4B,EAAE,CAAC;IAElD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;QACtC,IAAI,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,SAAS;QAEjC,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QAC/B,MAAM,WAAW,GAAG,sBAAsB,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;QAC1D,MAAM,eAAe,GAAG,WAAW,KAAK,SAAS,IAAI,cAAc,CAAC,WAAW,CAAC,CAAC;QAEjF,IAAI,UAAU,YAAY,UAAU,EAAE,CAAC;YACrC,IAAI,eAAe,EAAE,CAAC;gBACpB,iBAAiB,CAAC,QAAQ,EAAE,GAAG,EAAE,UAAU,CAAC,CAAC;YAC/C,CAAC;iBAAM,CAAC;gBACL,QAAoC,CAAC,GAAG,CAAC,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC;YAChE,CAAC;QACH,CAAC;aAAM,IAAI,UAAU,YAAY,QAAQ,EAAE,CAAC;YAC1C,IAAI,WAAW,EAAE,CAAC;gBAChB,WAAW,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,EAAE,CAAC,CAAC;YACpC,CAAC;iBAAM,CAAC;gBACL,QAAoC,CAAC,GAAG,CAAC,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC;YAChE,CAAC;YACD,MAAM,GAAG,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,QAAiB,EAAE,EAAE;gBACrD,IAAI,WAAW,EAAE,CAAC;oBAChB,WAAW,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;gBAC5B,CAAC;qBAAM,CAAC;oBACL,QAAoC,CAAC,GAAG,CAAC,GAAG,QAAQ,CAAC;gBACxD,CAAC;YACH,CAAC,CAAC,CAAC;YACH,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACxB,IAAI,OAAO,EAAE,CAAC;gBACZ,kBAAkB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YACnD,CAAC;QACH,CAAC;aAAM,CAAC;YACL,QAAoC,CAAC,GAAG,CAAC,GAAG,UAAU,CAAC;QAC1D,CAAC;IACH,CAAC;IAED,OAAO,EAAE,aAAa,EAAE,CAAC;AAC3B,CAAC"}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# Tapout
|
|
2
|
+
|
|
3
|
+
> Tapout is a modern ESM reactivity and templating library, spiritually inspired by KnockoutJS. Same philosophy, modern implementation.
|
|
4
|
+
|
|
5
|
+
## Guidance
|
|
6
|
+
|
|
7
|
+
- Prefer `@reactive accessor` over `new Observable()` for class properties
|
|
8
|
+
- Prefer `@reactiveArray accessor` over `new ObservableArray()` for array properties
|
|
9
|
+
- Prefer `@computed` over `new Computed()` for derived values in classes
|
|
10
|
+
- Prefer `@component` decorator over `components.register()` for component registration
|
|
11
|
+
- Use `tapout/core` when you only need reactivity (no DOM)
|
|
12
|
+
- Use `tapout` when you need DOM bindings, templates, and components
|
|
13
|
+
- Always dispose subscriptions and effects when no longer needed
|
|
14
|
+
- Use `DisposableGroup` for centralized cleanup
|
|
15
|
+
- Use `PureComputed` (or `@computed`, which uses it) for intermittent UI values
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install tapout
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
ESM-only. Two entry points:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { Observable, applyBindings } from 'tapout'; // full library
|
|
27
|
+
import { Observable, Computed } from 'tapout/core'; // reactivity only, no DOM
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start (Preferred Style)
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { reactive, computed, component, applyBindings } from 'tapout';
|
|
34
|
+
|
|
35
|
+
@component('greeting-card', `
|
|
36
|
+
<p>First: <input data-bind="textInput: firstName" /></p>
|
|
37
|
+
<p>Last: <input data-bind="textInput: lastName" /></p>
|
|
38
|
+
<h2 data-bind="text: fullName"></h2>
|
|
39
|
+
`)
|
|
40
|
+
class GreetingCard {
|
|
41
|
+
@reactive accessor firstName = 'Jane';
|
|
42
|
+
@reactive accessor lastName = 'Doe';
|
|
43
|
+
|
|
44
|
+
@computed get fullName() {
|
|
45
|
+
return `${this.firstName} ${this.lastName}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
applyBindings({}, document.body);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<greeting-card></greeting-card>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Core Reactivity
|
|
57
|
+
|
|
58
|
+
### Observable
|
|
59
|
+
|
|
60
|
+
Mutable reactive value. Reading inside a computed or effect tracks the dependency.
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
const count = new Observable(0);
|
|
64
|
+
count.get(); // read (tracks dependency)
|
|
65
|
+
count.peek(); // read without tracking
|
|
66
|
+
count.set(5); // write, notifies subscribers
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Decorator equivalent (preferred in classes):
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
class MyClass {
|
|
73
|
+
@reactive accessor count = 0;
|
|
74
|
+
}
|
|
75
|
+
// Reading: this.count (tracks), Writing: this.count = 5 (notifies)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Pass extender options: `@reactive({ deferred: true }) accessor query = '';`
|
|
79
|
+
|
|
80
|
+
### ObservableArray
|
|
81
|
+
|
|
82
|
+
Observable wrapping an array with mutation methods: `push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse`, `remove`, `removeAll`, `replace`.
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
const items = new ObservableArray(['a', 'b']);
|
|
86
|
+
items.push('c');
|
|
87
|
+
items.remove('a');
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Decorator equivalent (preferred in classes):
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
class MyClass {
|
|
94
|
+
@reactiveArray accessor items: string[] = [];
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Subscribe to fine-grained diffs:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
items.subscribe(changes => { /* status, value, index */ }, 'arrayChange');
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Computed
|
|
105
|
+
|
|
106
|
+
Derived value, re-evaluates when dependencies change.
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
const area = new Computed(() => width.get() * height.get());
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Writable computed:
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
const full = new Computed({
|
|
116
|
+
read: () => `${first.get()} ${last.get()}`,
|
|
117
|
+
write: (v: string) => { [first, last] = v.split(' ').map(s => s); },
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Decorator equivalent (preferred in classes):
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
class MyClass {
|
|
125
|
+
@reactive accessor first = 'John';
|
|
126
|
+
@reactive accessor last = 'Doe';
|
|
127
|
+
|
|
128
|
+
@computed get full() { return `${this.first} ${this.last}`; }
|
|
129
|
+
set full(v: string) { [this.first, this.last] = v.split(' '); }
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### PureComputed
|
|
134
|
+
|
|
135
|
+
Memory-optimized computed that sleeps when it has no subscribers. `@computed` uses this internally.
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
const label = new PureComputed(() => `Count: ${count.get()}`);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Effects
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// Runs immediately, then on each dependency change
|
|
145
|
+
const handle = effect(() => name.get(), val => console.log(val));
|
|
146
|
+
|
|
147
|
+
// Runs only on subsequent changes (not immediately)
|
|
148
|
+
const handle = observe(() => name.get(), val => console.log(val));
|
|
149
|
+
|
|
150
|
+
handle.dispose(); // stop
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Subscriptions
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
const sub = count.subscribe(value => console.log(value));
|
|
157
|
+
sub.dispose();
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Event channels: `'change'` (default), `'beforeChange'`, `'spectate'`, `'dirty'`, `'awake'`, `'asleep'`, `'arrayChange'`.
|
|
161
|
+
|
|
162
|
+
### Events
|
|
163
|
+
|
|
164
|
+
Stateless hot signals with owner/consumer separation:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
class MyService {
|
|
168
|
+
private _onSave = new Event<SaveEvent>();
|
|
169
|
+
readonly onSave = this._onSave.subscribable;
|
|
170
|
+
|
|
171
|
+
save(id: number) { this._onSave.emit(new SaveEvent(id, true)); }
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Type-filtered: `event.subscribable.on(SaveEvent).subscribe(...)`.
|
|
176
|
+
Aggregate: `AggregateEvent` rolls up multiple event sources via `pipe()`.
|
|
177
|
+
|
|
178
|
+
### DisposableGroup
|
|
179
|
+
|
|
180
|
+
Centralized cleanup for subscriptions and event subscriptions:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
private _subs = new DisposableGroup();
|
|
184
|
+
// this._subs.add(someSubscription);
|
|
185
|
+
// this._subs.dispose(); // cleans up all at once
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Extenders
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
obs.extend({ rateLimit: { timeout: 300, method: 'notifyWhenChangesStop' } });
|
|
192
|
+
obs.extend({ rateLimit: 200 }); // throttle
|
|
193
|
+
obs.extend({ notify: 'always' }); // always notify
|
|
194
|
+
obs.extend({ deferred: true }); // microtask batching
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Decorators (Preferred API)
|
|
198
|
+
|
|
199
|
+
| Decorator | Target | Backing |
|
|
200
|
+
|---|---|---|
|
|
201
|
+
| `@reactive` | `accessor` property | `Observable` |
|
|
202
|
+
| `@reactiveArray` | `accessor` property | `ObservableArray` |
|
|
203
|
+
| `@computed` | getter, getter+setter, method | `PureComputed` / writable `Computed` |
|
|
204
|
+
| `@component(tag, template)` | class | registers component |
|
|
205
|
+
|
|
206
|
+
Access underlying observable: `getObservable(instance, 'propName')`.
|
|
207
|
+
Replace underlying observable: `replaceObservable(instance, 'propName', newObs)`.
|
|
208
|
+
|
|
209
|
+
## Components
|
|
210
|
+
|
|
211
|
+
### Registration (decorator preferred)
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
@component('user-card', '<div><span data-bind="text: name"></span></div>')
|
|
215
|
+
class UserCard {
|
|
216
|
+
@reactive accessor name = '';
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Or with options:
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
@component({ tag: 'user-card', template: '...', synchronous: true })
|
|
224
|
+
class UserCard { ... }
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Manual registration (use decorator instead when possible):
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
components.register('user-card', { template: '...', viewModel: UserCard });
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Usage
|
|
234
|
+
|
|
235
|
+
```html
|
|
236
|
+
<user-card params="name: userName"></user-card>
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Params and Wiring
|
|
240
|
+
|
|
241
|
+
- Plain values assigned directly
|
|
242
|
+
- Computed params create one-way subscription
|
|
243
|
+
- `$`-prefixed observable params enable two-way binding
|
|
244
|
+
|
|
245
|
+
```html
|
|
246
|
+
<user-card params="name: $sharedName"></user-card>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Lifecycle
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
onInit() // after creation and param wiring
|
|
253
|
+
onDescendantsComplete(node) // after DOM is ready
|
|
254
|
+
dispose() // on cleanup
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Slots
|
|
258
|
+
|
|
259
|
+
```html
|
|
260
|
+
<!-- Component template -->
|
|
261
|
+
<div data-bind="slot: 'header'">Default</div>
|
|
262
|
+
<div data-bind="slot: ''">Default body</div>
|
|
263
|
+
|
|
264
|
+
<!-- Usage -->
|
|
265
|
+
<my-card>
|
|
266
|
+
<h2 slot="header">Custom Title</h2>
|
|
267
|
+
<p>Body content</p>
|
|
268
|
+
</my-card>
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Binding System
|
|
272
|
+
|
|
273
|
+
Apply bindings: `applyBindings(vm, rootElement)`.
|
|
274
|
+
|
|
275
|
+
### Context
|
|
276
|
+
|
|
277
|
+
`$data`, `$root`, `$parent`, `$parents`, `$index`, `$component`, `$parentContext`, `$rawData`, `$componentTemplateNodes`.
|
|
278
|
+
|
|
279
|
+
Inspect: `contextFor(element)`, `dataFor(element)`.
|
|
280
|
+
|
|
281
|
+
### Built-in Bindings
|
|
282
|
+
|
|
283
|
+
**Display:** `text`, `html`, `visible`, `hidden`.
|
|
284
|
+
**Attributes:** `attr: { href: url }`, `css: { active: isActive }`, `class: className`, `style: { color: c }`.
|
|
285
|
+
**Form state:** `enable`, `disable`, `uniqueName`.
|
|
286
|
+
**Events:** `event: { mouseover: fn }`, `click`, `submit`, `enter`. Return `true` for default behavior. Use `clickBubble: false` to stop bubbling.
|
|
287
|
+
**Two-way:** `value`, `textInput`, `checked`, `checkedValue`, `hasFocus`, `selectedOptions`, `options`.
|
|
288
|
+
**Control flow:** `if`, `ifnot`, `with`, `using`, `let`, `foreach`, `template`.
|
|
289
|
+
**Dialog:** `modal`.
|
|
290
|
+
|
|
291
|
+
### Virtual Elements
|
|
292
|
+
|
|
293
|
+
```html
|
|
294
|
+
<!-- tap if: showSection -->
|
|
295
|
+
<p>Content</p>
|
|
296
|
+
<!-- /tap -->
|
|
297
|
+
|
|
298
|
+
<!-- tap foreach: items -->
|
|
299
|
+
<span data-bind="text: $data"></span>
|
|
300
|
+
<!-- /tap -->
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Interpolation Markup
|
|
304
|
+
|
|
305
|
+
Enable with `enableInterpolationMarkup()`:
|
|
306
|
+
|
|
307
|
+
```html
|
|
308
|
+
<span>Hello, {{ name }}!</span>
|
|
309
|
+
<div>{{{ richHtml }}}</div>
|
|
310
|
+
{{# if isLoggedIn }}<p>Welcome!</p>{{/ if }}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Attribute Interpolation
|
|
314
|
+
|
|
315
|
+
Enable with `enableAttributeInterpolationMarkup()`:
|
|
316
|
+
|
|
317
|
+
```html
|
|
318
|
+
<a href="{{ baseUrl }}/profile/{{ userId }}">Profile</a>
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Namespaced Bindings
|
|
322
|
+
|
|
323
|
+
Enable with `enableNamespacedBindings()`:
|
|
324
|
+
|
|
325
|
+
```html
|
|
326
|
+
<a data-bind="attr.href: url, css.active: isActive">Link</a>
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Filters
|
|
330
|
+
|
|
331
|
+
Enable with `enableTextFilter('text')`:
|
|
332
|
+
|
|
333
|
+
```html
|
|
334
|
+
<span data-bind="text: name | uppercase"></span>
|
|
335
|
+
<span data-bind="text: bio | default:'None'"></span>
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Built-in: `uppercase`, `lowercase`, `default`, `json`. Custom: `filters['myFilter'] = (val, ...args) => result`.
|
|
339
|
+
|
|
340
|
+
## Utilities
|
|
341
|
+
|
|
342
|
+
- `toJS(obj)` — deep-unwrap observables to plain JS
|
|
343
|
+
- `toJSON(obj)` — JSON string of unwrapped object
|
|
344
|
+
- `when(() => condition, callback)` — one-shot reactive wait (or returns Promise if no callback)
|
|
345
|
+
- `unwrapObservable(val)` — recursively unwrap
|
|
346
|
+
- `cleanNode(el)`, `removeNode(el)`, `addDisposeCallback(el, fn)` — DOM cleanup
|
|
347
|
+
|
|
348
|
+
## Configuration
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
import { options } from 'tapout';
|
|
352
|
+
options.deferUpdates = true; // global microtask batching
|
|
353
|
+
options.onError = (err) => { ... }; // global error handler
|
|
354
|
+
options.viewModelFactory = (ctor) => new ctor(); // DI hook
|
|
355
|
+
options.customElementDisplayContents = true; // display: contents on custom elements
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Type Guards
|
|
359
|
+
|
|
360
|
+
`isObservable(v)`, `isObservableArray(v)`, `isComputed(v)`, `isPureComputed(v)`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thomas-siegfried/tapout",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"dist",
|
|
33
|
-
"src"
|
|
33
|
+
"src",
|
|
34
|
+
"llms.txt",
|
|
35
|
+
"decorator-config.md"
|
|
34
36
|
],
|
|
35
37
|
"scripts": {
|
|
36
38
|
"build": "tsc",
|
package/src/wireParams.ts
CHANGED
|
@@ -7,6 +7,18 @@ import { addDisposeCallback } from './domNodeDisposal.js';
|
|
|
7
7
|
|
|
8
8
|
const SKIP_KEYS = new Set(['$raw']);
|
|
9
9
|
|
|
10
|
+
function getWritableChildTarget(instance: object, key: string): Observable<unknown> | Computed<unknown> | undefined {
|
|
11
|
+
const decorated = getObservable(instance, key);
|
|
12
|
+
if (decorated instanceof Observable) return decorated;
|
|
13
|
+
if (decorated instanceof Computed && decorated.hasWriteFunction) return decorated;
|
|
14
|
+
|
|
15
|
+
const directValue = (instance as Record<string, unknown>)[key];
|
|
16
|
+
if (directValue instanceof Observable) return directValue;
|
|
17
|
+
if (directValue instanceof Computed && directValue.hasWriteFunction) return directValue;
|
|
18
|
+
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
export interface WireParamsResult {
|
|
11
23
|
subscriptions: Subscription<unknown>[];
|
|
12
24
|
}
|
|
@@ -22,8 +34,8 @@ export function wireParams(
|
|
|
22
34
|
if (SKIP_KEYS.has(key)) continue;
|
|
23
35
|
|
|
24
36
|
const paramValue = params[key];
|
|
25
|
-
const
|
|
26
|
-
const childIsReactive =
|
|
37
|
+
const childTarget = getWritableChildTarget(instance, key);
|
|
38
|
+
const childIsReactive = childTarget !== undefined && isSubscribable(childTarget);
|
|
27
39
|
|
|
28
40
|
if (paramValue instanceof Observable) {
|
|
29
41
|
if (childIsReactive) {
|
|
@@ -32,9 +44,17 @@ export function wireParams(
|
|
|
32
44
|
(instance as Record<string, unknown>)[key] = paramValue.get();
|
|
33
45
|
}
|
|
34
46
|
} else if (paramValue instanceof Computed) {
|
|
35
|
-
(
|
|
47
|
+
if (childTarget) {
|
|
48
|
+
childTarget.set(paramValue.get());
|
|
49
|
+
} else {
|
|
50
|
+
(instance as Record<string, unknown>)[key] = paramValue.get();
|
|
51
|
+
}
|
|
36
52
|
const sub = paramValue.subscribe((newValue: unknown) => {
|
|
37
|
-
(
|
|
53
|
+
if (childTarget) {
|
|
54
|
+
childTarget.set(newValue);
|
|
55
|
+
} else {
|
|
56
|
+
(instance as Record<string, unknown>)[key] = newValue;
|
|
57
|
+
}
|
|
38
58
|
});
|
|
39
59
|
subscriptions.push(sub);
|
|
40
60
|
if (element) {
|