@zvenigora/ng-eval-forms 0.1.0

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.
@@ -0,0 +1,132 @@
1
+ import { createSignalContext } from '@zvenigora/ng-eval-signals';
2
+
3
+ /**
4
+ * Coerces an expression's result to the boolean a `visible` rule needs.
5
+ *
6
+ * **JavaScript truthiness, and nothing cleverer** (plan S 3.6). A rule author
7
+ * writing `visible: "country === 'US'"` gets a boolean already; the coercion
8
+ * exists for the ones who write `visible: "country"`.
9
+ *
10
+ * `undefined` is therefore the load-bearing input rather than an edge case.
11
+ * An *empty* field and a *missing* field are indistinguishable here - S 3.4.3:
12
+ * a field key bound to `undefined` is treated as absent, and an empty
13
+ * `FormControl` is the common case - so both arrive as `undefined`, and both
14
+ * mean "not visible".
15
+ *
16
+ * The string `'false'` is **visible**, because it is a non-empty string. That
17
+ * is truthiness rather than parsing, and it is the answer a form-builder UI
18
+ * storing every value as a string will hit. Documented in the README rather
19
+ * than special-cased: a coercion that parsed `'false'` would then owe an
20
+ * answer for `'no'`, `'0'` and `'off'`, none of which JavaScript has one for.
21
+ *
22
+ * Both adapters call this, which is why it is in the core: `/reactive` and
23
+ * `/signals` differ in how they schedule the recompute and not in what the
24
+ * result means (S 3.4.5).
25
+ *
26
+ * @param value - Whatever the expression evaluated to.
27
+ * @returns `true` when the value is truthy.
28
+ */
29
+ const toVisible = (value) => !!value;
30
+ /**
31
+ * Coerces an expression's result to the string a `text` rule needs.
32
+ *
33
+ * `String(value)`, with `null` and `undefined` mapping to `''` (plan S 3.6).
34
+ * Those two are the carve-out and the only one: rendering the literal text
35
+ * `undefined` into a label is the failure this rule exists to prevent, and it
36
+ * is what a bare `String(value)` would do for the most ordinary input a form
37
+ * has - the empty control of S 3.4.3.
38
+ *
39
+ * Every *other* falsy value stringifies normally: `0` is `'0'` and `false` is
40
+ * `'false'`. Mapping all falsy values to `''` would be the obvious way to
41
+ * write this rule wrongly, and it would blank a field whose value is
42
+ * legitimately zero.
43
+ *
44
+ * @param value - Whatever the expression evaluated to.
45
+ * @returns The value as a string, or `''` when it is `null` or `undefined`.
46
+ */
47
+ const toText = (value) => value === null || value === undefined ? '' : String(value);
48
+
49
+ /**
50
+ * Composes one `EvalContext` for one field, out of two live sources.
51
+ *
52
+ * The mechanism is **two lookups, not a joined record** (plan S 3.4.2). The
53
+ * field half is a `createSignalContext` over `fieldSource`; the form half is a
54
+ * second resolver pushed onto the same context's `lookups`. Both close over
55
+ * their source and read it at resolve time, so a key added to either after
56
+ * construction resolves - there is no third object to keep in sync, and no
57
+ * write-through to N contexts.
58
+ *
59
+ * `EvalContext.get` walks `lookups` in order and takes the first
60
+ * non-`undefined`, so the field half is consulted first.
61
+ *
62
+ * One context per field, never one shared across fields (plan S 3.4.1):
63
+ * `get` resolves `scopes` and `original` *before* `lookups`, so anything left
64
+ * on a shared context - an arrow function's leaked scope, say - would be read
65
+ * ahead of every other field's source key of the same name, for the life of
66
+ * the form.
67
+ *
68
+ * **Resolution is live; appearance is not reactive.** An expression that read
69
+ * a then-missing key subscribed to nothing, so the owner of the source calls
70
+ * `invalidate()` when the *key set* changes. A change to a *value* is
71
+ * Angular's job and needs nothing.
72
+ *
73
+ * **Precedence is value-dependent, not permanent.** A field key wins while
74
+ * its value is *present*: `EvalContext.get` treats `undefined` as absent at
75
+ * every step, and the field resolver returns `undefined` both for "no such
76
+ * key" and for "key bound to `undefined`" - so an empty `FormControl`, which
77
+ * is the common case rather than an exotic one, falls through to the form
78
+ * value. Documented as a limitation (plan S 3.4.3): distinguishing the two
79
+ * would need a sentinel threaded through `EvalContext.get`, which is
80
+ * `eval-core`'s and out of this phase's scope.
81
+ *
82
+ * @param formSource - The form-wide keys, shared by every field of the form.
83
+ * @param fieldSource - The field-local keys. These win on a name collision,
84
+ * while their value is not `undefined`.
85
+ * @param options - Passed to `createSignalContext` for **both** halves;
86
+ * configures this context and its resolvers, not the walk.
87
+ * As upstream, `caseInsensitive` corrects identifier keys
88
+ * here but not *property* names - the member visitor reads
89
+ * those off the state's options, so the same options must
90
+ * also reach `simpleEval` / `createState`.
91
+ */
92
+ const createFieldContext = (formSource, fieldSource, options) => {
93
+ const context = createSignalContext(fieldSource, options);
94
+ // The form half **borrows upstream's resolver rather than rewriting it**
95
+ // (S 3.4.2, candidate A, settled in step 2). The second context is
96
+ // discarded and only its closure survives, so this is still one
97
+ // `EvalContext` per field and the field half is still consulted first.
98
+ //
99
+ // The deciding reason is row 2 of S 3.4.2's measured table, and it is that
100
+ // the hand-written read is *wrong*, not merely thinner: it returns a signal
101
+ // un-called, so a form key holding `signal(undefined)` resolves to the
102
+ // signal **function**, which is truthy - and `visible: "country"` then
103
+ // renders a field precisely when its value is absent, silently, on an empty
104
+ // control. That is every form's first render. Recorded here because the
105
+ // alternative was rejected on correctness and stays rejected even once
106
+ // S 3.5 names a mechanism that keeps a plain-value record live.
107
+ //
108
+ // Borrowing also brings own-property semantics - so a server-supplied field
109
+ // named `constructor` cannot resolve off `Object.prototype` *through this
110
+ // half*, which is a narrower claim than it looks and no test reaches it:
111
+ // `get` consults `original` before `lookups`, and `getContextValue` reads a
112
+ // plain object as a bare property access, so an inherited name resolves
113
+ // there first and never arrives here (S 3.4.3's third layer, upstream and
114
+ // out of this phase's scope). It also brings `caseInsensitive` correction
115
+ // and `warnOnNestedSignals` over the form source, with no import beyond the
116
+ // one already here.
117
+ //
118
+ // The scan is the one cost: `createSignalContext` runs
119
+ // `warnOnNestedSignals` unconditionally, so a form of N fields scans the
120
+ // same form source N times and, in dev mode, would warn N times under
121
+ // *upstream's* symbol name for a call this library made. Construction-time
122
+ // only - no per-node or per-recompute path is touched.
123
+ context.lookups.push(...createSignalContext(formSource, options).lookups);
124
+ return context;
125
+ };
126
+
127
+ /**
128
+ * Generated bundle index. Do not edit.
129
+ */
130
+
131
+ export { createFieldContext, toText, toVisible };
132
+ //# sourceMappingURL=zvenigora-ng-eval-forms.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zvenigora-ng-eval-forms.mjs","sources":["../../../../modules/eval-forms/src/lib/coercion.ts","../../../../modules/eval-forms/src/lib/field-context.ts","../../../../modules/eval-forms/src/zvenigora-ng-eval-forms.ts"],"sourcesContent":["/**\r\n * Coerces an expression's result to the boolean a `visible` rule needs.\r\n *\r\n * **JavaScript truthiness, and nothing cleverer** (plan S 3.6). A rule author\r\n * writing `visible: \"country === 'US'\"` gets a boolean already; the coercion\r\n * exists for the ones who write `visible: \"country\"`.\r\n *\r\n * `undefined` is therefore the load-bearing input rather than an edge case.\r\n * An *empty* field and a *missing* field are indistinguishable here - S 3.4.3:\r\n * a field key bound to `undefined` is treated as absent, and an empty\r\n * `FormControl` is the common case - so both arrive as `undefined`, and both\r\n * mean \"not visible\".\r\n *\r\n * The string `'false'` is **visible**, because it is a non-empty string. That\r\n * is truthiness rather than parsing, and it is the answer a form-builder UI\r\n * storing every value as a string will hit. Documented in the README rather\r\n * than special-cased: a coercion that parsed `'false'` would then owe an\r\n * answer for `'no'`, `'0'` and `'off'`, none of which JavaScript has one for.\r\n *\r\n * Both adapters call this, which is why it is in the core: `/reactive` and\r\n * `/signals` differ in how they schedule the recompute and not in what the\r\n * result means (S 3.4.5).\r\n *\r\n * @param value - Whatever the expression evaluated to.\r\n * @returns `true` when the value is truthy.\r\n */\r\nexport const toVisible = (value: unknown): boolean => !!value;\r\n\r\n/**\r\n * Coerces an expression's result to the string a `text` rule needs.\r\n *\r\n * `String(value)`, with `null` and `undefined` mapping to `''` (plan S 3.6).\r\n * Those two are the carve-out and the only one: rendering the literal text\r\n * `undefined` into a label is the failure this rule exists to prevent, and it\r\n * is what a bare `String(value)` would do for the most ordinary input a form\r\n * has - the empty control of S 3.4.3.\r\n *\r\n * Every *other* falsy value stringifies normally: `0` is `'0'` and `false` is\r\n * `'false'`. Mapping all falsy values to `''` would be the obvious way to\r\n * write this rule wrongly, and it would blank a field whose value is\r\n * legitimately zero.\r\n *\r\n * @param value - Whatever the expression evaluated to.\r\n * @returns The value as a string, or `''` when it is `null` or `undefined`.\r\n */\r\nexport const toText = (value: unknown): string =>\r\n value === null || value === undefined ? '' : String(value);\r\n","import { EvalContext, EvalOptions } from '@zvenigora/ng-eval-core';\r\nimport { SignalContextSource, createSignalContext } from '@zvenigora/ng-eval-signals';\r\n\r\n/**\r\n * Composes one `EvalContext` for one field, out of two live sources.\r\n *\r\n * The mechanism is **two lookups, not a joined record** (plan S 3.4.2). The\r\n * field half is a `createSignalContext` over `fieldSource`; the form half is a\r\n * second resolver pushed onto the same context's `lookups`. Both close over\r\n * their source and read it at resolve time, so a key added to either after\r\n * construction resolves - there is no third object to keep in sync, and no\r\n * write-through to N contexts.\r\n *\r\n * `EvalContext.get` walks `lookups` in order and takes the first\r\n * non-`undefined`, so the field half is consulted first.\r\n *\r\n * One context per field, never one shared across fields (plan S 3.4.1):\r\n * `get` resolves `scopes` and `original` *before* `lookups`, so anything left\r\n * on a shared context - an arrow function's leaked scope, say - would be read\r\n * ahead of every other field's source key of the same name, for the life of\r\n * the form.\r\n *\r\n * **Resolution is live; appearance is not reactive.** An expression that read\r\n * a then-missing key subscribed to nothing, so the owner of the source calls\r\n * `invalidate()` when the *key set* changes. A change to a *value* is\r\n * Angular's job and needs nothing.\r\n *\r\n * **Precedence is value-dependent, not permanent.** A field key wins while\r\n * its value is *present*: `EvalContext.get` treats `undefined` as absent at\r\n * every step, and the field resolver returns `undefined` both for \"no such\r\n * key\" and for \"key bound to `undefined`\" - so an empty `FormControl`, which\r\n * is the common case rather than an exotic one, falls through to the form\r\n * value. Documented as a limitation (plan S 3.4.3): distinguishing the two\r\n * would need a sentinel threaded through `EvalContext.get`, which is\r\n * `eval-core`'s and out of this phase's scope.\r\n *\r\n * @param formSource - The form-wide keys, shared by every field of the form.\r\n * @param fieldSource - The field-local keys. These win on a name collision,\r\n * while their value is not `undefined`.\r\n * @param options - Passed to `createSignalContext` for **both** halves;\r\n * configures this context and its resolvers, not the walk.\r\n * As upstream, `caseInsensitive` corrects identifier keys\r\n * here but not *property* names - the member visitor reads\r\n * those off the state's options, so the same options must\r\n * also reach `simpleEval` / `createState`.\r\n */\r\nexport const createFieldContext = (\r\n formSource: SignalContextSource,\r\n fieldSource: SignalContextSource,\r\n options?: EvalOptions\r\n): EvalContext => {\r\n\r\n const context = createSignalContext(fieldSource, options);\r\n\r\n // The form half **borrows upstream's resolver rather than rewriting it**\r\n // (S 3.4.2, candidate A, settled in step 2). The second context is\r\n // discarded and only its closure survives, so this is still one\r\n // `EvalContext` per field and the field half is still consulted first.\r\n //\r\n // The deciding reason is row 2 of S 3.4.2's measured table, and it is that\r\n // the hand-written read is *wrong*, not merely thinner: it returns a signal\r\n // un-called, so a form key holding `signal(undefined)` resolves to the\r\n // signal **function**, which is truthy - and `visible: \"country\"` then\r\n // renders a field precisely when its value is absent, silently, on an empty\r\n // control. That is every form's first render. Recorded here because the\r\n // alternative was rejected on correctness and stays rejected even once\r\n // S 3.5 names a mechanism that keeps a plain-value record live.\r\n //\r\n // Borrowing also brings own-property semantics - so a server-supplied field\r\n // named `constructor` cannot resolve off `Object.prototype` *through this\r\n // half*, which is a narrower claim than it looks and no test reaches it:\r\n // `get` consults `original` before `lookups`, and `getContextValue` reads a\r\n // plain object as a bare property access, so an inherited name resolves\r\n // there first and never arrives here (S 3.4.3's third layer, upstream and\r\n // out of this phase's scope). It also brings `caseInsensitive` correction\r\n // and `warnOnNestedSignals` over the form source, with no import beyond the\r\n // one already here.\r\n //\r\n // The scan is the one cost: `createSignalContext` runs\r\n // `warnOnNestedSignals` unconditionally, so a form of N fields scans the\r\n // same form source N times and, in dev mode, would warn N times under\r\n // *upstream's* symbol name for a call this library made. Construction-time\r\n // only - no per-node or per-recompute path is touched.\r\n context.lookups.push(...createSignalContext(formSource, options).lookups);\r\n\r\n return context;\r\n};\r\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":";;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;AAyBG;AACI,MAAM,SAAS,GAAG,CAAC,KAAc,KAAc,CAAC,CAAC;AAExD;;;;;;;;;;;;;;;;AAgBG;AACI,MAAM,MAAM,GAAG,CAAC,KAAc,KACnC,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,GAAG,EAAE,GAAG,MAAM,CAAC,KAAK;;AC3C3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CG;AACI,MAAM,kBAAkB,GAAG,CAChC,UAA+B,EAC/B,WAAgC,EAChC,OAAqB,KACN;IAEf,MAAM,OAAO,GAAG,mBAAmB,CAAC,WAAW,EAAE,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BzD,IAAA,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC;AAEzE,IAAA,OAAO,OAAO;AAChB;;ACtFA;;AAEG;;;;"}
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@zvenigora/ng-eval-forms",
3
+ "version": "0.1.0",
4
+ "description": "Angular form field properties from ng-eval expressions",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/zvenigora/ng-eval.git"
8
+ },
9
+ "keywords": [
10
+ "Angular",
11
+ "Eval",
12
+ "Forms"
13
+ ],
14
+ "author": "Zvenigora (https://github.com/zvenigora)",
15
+ "license": "MIT",
16
+ "homepage": "https://github.com/Zvenigora/ng-eval#readme",
17
+ "peerDependencies": {
18
+ "@angular/core": ">=19.0.0",
19
+ "@angular/forms": ">=19.0.0",
20
+ "rxjs": "^7.8.0",
21
+ "@zvenigora/ng-eval-core": "^0.3.0",
22
+ "@zvenigora/ng-eval-signals": "^0.1.0"
23
+ },
24
+ "sideEffects": false,
25
+ "module": "fesm2022/zvenigora-ng-eval-forms.mjs",
26
+ "typings": "types/zvenigora-ng-eval-forms.d.ts",
27
+ "exports": {
28
+ "./package.json": {
29
+ "default": "./package.json"
30
+ },
31
+ ".": {
32
+ "types": "./types/zvenigora-ng-eval-forms.d.ts",
33
+ "default": "./fesm2022/zvenigora-ng-eval-forms.mjs"
34
+ },
35
+ "./reactive": {
36
+ "types": "./types/zvenigora-ng-eval-forms-reactive.d.ts",
37
+ "default": "./fesm2022/zvenigora-ng-eval-forms-reactive.mjs"
38
+ }
39
+ },
40
+ "type": "module",
41
+ "dependencies": {
42
+ "tslib": "^2.3.0"
43
+ }
44
+ }
@@ -0,0 +1,221 @@
1
+ import { Injector } from '@angular/core';
2
+ import { FormGroup } from '@angular/forms';
3
+ import { SignalContextSource, EvalSignal } from '@zvenigora/ng-eval-signals';
4
+ import { ExpressionErrorPolicy } from '@zvenigora/ng-eval-forms';
5
+
6
+ /**
7
+ * Builds the form-wide source of an expression context from a `FormGroup`.
8
+ *
9
+ * One mirror **per control, never per group** (plan S 3.5.1). A disabled
10
+ * control is excluded from its parent's aggregate value and there is no
11
+ * `rawValueChanges` to go with `getRawValue()`, so a group-backed mirror would
12
+ * lose a field the moment anything disabled it - and this library computes
13
+ * disablement, so one field's rule would silently blank the value every *other*
14
+ * field's rules resolve against. A control's own `valueChanges` keeps emitting
15
+ * regardless of its enabled state.
16
+ *
17
+ * **The record holds live values behind accessors, not the mirrored signals**
18
+ * (S 3.5.5). Each key is an enumerable accessor that reads its change-signal -
19
+ * which is what a surrounding `computed()` records as a dependency, per key -
20
+ * and then answers with the control's *current* value. Reading the control
21
+ * rather than the signal is what makes `invalidate()` a working hatch for
22
+ * trap 3: a `{ emitEvent: false }` write leaves the change-signal stale, and a
23
+ * record that handed out that signal would have nothing to recover, however
24
+ * often it was re-evaluated.
25
+ *
26
+ * **One entry per key, for the life of that key** (S 3.5.5). A replaced control
27
+ * instance re-points its channel; it does not get a new accessor or a new
28
+ * signal. Building fresh ones would leave every property that had already read
29
+ * the key tracking the *dead* control's signal - frozen at its last value, for
30
+ * the life of the binding, which is the failure trap 5 exists to prevent
31
+ * reappearing one layer up.
32
+ *
33
+ * **The returned record must be passed by reference, never spread or cloned.**
34
+ * `{ ...source }` flattens the accessors to the values they happened to hold,
35
+ * so the copy records no dependency and every property over it freezes at its
36
+ * first value with no error. The type - `Record<string, unknown>` - gives no
37
+ * hint of this, and phase-3-plan S 9.2 describes composing sources by
38
+ * spreading, which is why it is said here.
39
+ *
40
+ * **`invalidate()` covers a stale *value*, not a suppressed *control-set*
41
+ * change.** Trap 3's hatch works because the accessor re-reads the control, so
42
+ * re-running the walk recovers. `setControl` / `addControl` / `removeControl`
43
+ * with `{ emitEvent: false }` suppress `group.events` instead, so `sync` never
44
+ * runs and the channel still points at the dead instance - there is nothing
45
+ * for a re-run to recover, in the same structural sense that rules out a
46
+ * signal-valued record. Current behaviour, pinned here rather than fixed:
47
+ * the observable is the only signal there is.
48
+ *
49
+ * @param group - A flat `FormGroup`. Nested groups and `FormArray` are out of
50
+ * scope for this phase.
51
+ * @param options - `injector` scopes every subscription here to the form's
52
+ * lifetime; it is required rather than optional because an
53
+ * explicit injector means no `DestroyRef` auto-teardown, and
54
+ * an optional one would silently vary that (plan S 3.7).
55
+ */
56
+ declare const createControlSource: (group: FormGroup, options: {
57
+ injector: Injector;
58
+ }) => SignalContextSource;
59
+
60
+ /**
61
+ * One field's rules, as strings resolved at runtime.
62
+ *
63
+ * This is the whole descriptor. It is deliberately not a schema *language*:
64
+ * `visible` and `text` are the two properties this phase ships (plan S 3.6),
65
+ * `disabled` is deferred with three reasons rather than a shrug, and
66
+ * `required` is deferred with the validators.
67
+ */
68
+ interface FieldSchema {
69
+ /**
70
+ * The field's name, and the key its control is mirrored under.
71
+ *
72
+ * It does not have to name a control: a rule naming a field the group has
73
+ * no control for resolves `undefined`, which is S 3.4.3's ordinary case
74
+ * rather than an error. It may not be a member of `Object.prototype` -
75
+ * see {@link bindFieldProperties}.
76
+ */
77
+ readonly name: string;
78
+ /** An expression whose value is coerced by truthiness (`toVisible`). */
79
+ readonly visible?: string;
80
+ /** An expression whose value is coerced to a string (`toText`). */
81
+ readonly text?: string;
82
+ }
83
+ /**
84
+ * The signals bound for one field - one per rule the schema supplied.
85
+ *
86
+ * Typed as `EvalSignal`, not `Signal`, and both extra members are load-bearing
87
+ * on this path (plan S 5): `invalidate()` is trap 3's hatch for a
88
+ * `{ emitEvent: false }` write and S 3.4.2's hatch for a key-set change, and
89
+ * `destroy()` is what step 5's teardown counts. Typing these as `Signal` would
90
+ * put both out of reach through the published surface.
91
+ *
92
+ * The type arguments are `boolean` and `string` rather than S 5's original
93
+ * `unknown`, **because the coercion of S 3.6 happens here**. That section was
94
+ * written before the coercion's placement was settled and `coercion.ts` states
95
+ * the settlement - "both adapters call this" - so `unknown` would now describe
96
+ * the value the property is built *from* rather than the one it answers with.
97
+ */
98
+ interface FieldProperties {
99
+ readonly visible?: EvalSignal<boolean>;
100
+ readonly text?: EvalSignal<string>;
101
+ }
102
+ /**
103
+ * What {@link bindFieldProperties} returns: the bound fields, and the one call
104
+ * that ends them.
105
+ *
106
+ * **Nested rather than a `destroy` written onto the record** (plan S 5's step-5
107
+ * amendment). The record is keyed by *field name*, `destroy` is a legal field
108
+ * name, and S 0's premise is that those names arrive from a server rather than
109
+ * from the consumer - so a flat shape would put a silent collision between the
110
+ * consumer's data and this library's API in precisely the case where the
111
+ * consumer controls the names least.
112
+ */
113
+ interface FormBinding {
114
+ /** The bound properties, keyed by field name. */
115
+ readonly fields: Record<string, FieldProperties>;
116
+ /**
117
+ * Destroys every `EvalSignal` this binding created and releases the mirror.
118
+ *
119
+ * **Call it.** Every signal here is built with an explicit injector, and
120
+ * that means no `DestroyRef` registration of its own (`eval-signal.ts`) - so
121
+ * nothing auto-destroys and all N x M are the caller's (plan S 3.7).
122
+ *
123
+ * There is one net under that, and it is a net rather than a substitute: the
124
+ * binding registers this on the `DestroyRef` of the injector it was given,
125
+ * so a binding wired to a component or route injector is released when that
126
+ * injector dies even if nobody called it. It is not a licence to skip the
127
+ * call - a binding built on the root injector is released at the end of the
128
+ * application and no sooner.
129
+ *
130
+ * Idempotent, and that is load-bearing rather than polite: the scope is an
131
+ * `EnvironmentInjector`, and `R3Injector.destroy()` throws NG0205 when it
132
+ * has already run.
133
+ */
134
+ destroy(): void;
135
+ }
136
+ /**
137
+ * Binds a schema to a `FormGroup`, producing one `EvalSignal` per rule.
138
+ *
139
+ * ```ts
140
+ * const binding = bindFieldProperties(
141
+ * [{ name: 'state', visible: "country === 'US'" }],
142
+ * form,
143
+ * { injector }
144
+ * );
145
+ *
146
+ * binding.fields['state'].visible(); // recomputes when `country` changes
147
+ *
148
+ * binding.destroy(); // yours to call - nothing here
149
+ * // auto-destroys (S 3.7)
150
+ * ```
151
+ *
152
+ * **One `EvalContext` per field, never one shared** (plan S 3.4.1). `get`
153
+ * resolves `scopes` and `original` *before* `lookups`, and
154
+ * `arrow-function-expression.ts` pushes its scope with no `try`/`finally` - so
155
+ * one field's arrow function that throws leaves a scope on the context for the
156
+ * life of the form, and under a shared context it would shadow every other
157
+ * field's key of the same name. The count is N contexts for N fields, and not
158
+ * N x M: the properties of one field resolve against the same names.
159
+ *
160
+ * **The field half of each context is empty in this phase.**
161
+ * `createFieldContext` takes two sources and the second is `{}` here. What a
162
+ * field-local key set should *contain* is specified nowhere - S 3.4.3's
163
+ * "a field named `value`, `name` or `index`" illustrates collision semantics
164
+ * rather than naming keys - and inventing three keys to fill a parameter is
165
+ * how a public surface acquires members nobody chose. The consequence, stated
166
+ * rather than left to be found: S 3.4.3's precedence rule is asserted at the
167
+ * core in `field-context.spec.ts` and ships **untested end to end**, because
168
+ * no `/reactive` path produces a field-local key. It gets settled by whichever
169
+ * phase first has a consumer for one.
170
+ *
171
+ * **`createControlSource` is called once, for the whole form.** Per field
172
+ * would build N mirrors over one group: N x the live `toSignal` subscriptions
173
+ * step 5 counts, and trap 5's O(N) diff running N times per `group.events`
174
+ * emission, on a signal that already fires several times per interaction.
175
+ *
176
+ * **Constructed, not computed.** `toSignal` opens with
177
+ * `assertNotInReactiveContext`, so calling this from inside an `effect()` or a
178
+ * `computed()` throws out of the mirror with an error naming `toSignal` and
179
+ * nothing naming this library. A form binding belongs in a service or a
180
+ * factory, which is the same premise `injector` being required rests on.
181
+ *
182
+ * **Teardown runs off a child injector, and there is only one of them**
183
+ * (S 3.7, amended in step 5). The binding opens an `EnvironmentInjector` under
184
+ * the caller's and scopes *both* the mirror and every `createEvalSignal` to it,
185
+ * so `destroy()` releases the whole mirror - every per-key `toSignal`
186
+ * subscription and the `group.events` one - in a single call, and a bind that
187
+ * throws part-way through releases exactly the same thing however far it got.
188
+ * The plan's earlier premise, that releasing the mirror needed a handle
189
+ * `createControlSource` does not return, was wrong: step 3 had already measured
190
+ * that destroying the injector drops every control's observer count to 0 and
191
+ * ends the diffing.
192
+ *
193
+ * The `EvalSignal`s are **not** covered by that, and that is the point of the
194
+ * count: built with an explicit injector, they take no `DestroyRef`
195
+ * registration, so `destroy()` walks them itself and N x M of them is what a
196
+ * spec asserts rather than "the form's destroy ran".
197
+ *
198
+ * @param schema - The fields to bind. Validated at construction: a duplicate
199
+ * name, a non-string rule, a name that resolves off
200
+ * `Object.prototype`, and a control that is not a
201
+ * `FormControl` all throw here rather than surfacing later as
202
+ * an evaluation result nobody can trace.
203
+ * @param group - A flat `FormGroup` of `FormControl`s.
204
+ * @param options - `injector` is **required**, not optional: S 3.7's argument
205
+ * is that every `EvalSignal` here has no auto-teardown, and
206
+ * an optional injector would silently vary that for a call
207
+ * that happened to sit in an injection context. `onError`
208
+ * defaults to `'undefined'` - the opposite of
209
+ * `eval-signals`' default, and resolved *here* rather than
210
+ * forwarded absent, since `createEvalSignal` would otherwise
211
+ * supply `'throw'` (S 3.4.4).
212
+ * @returns The bound properties keyed by field name, and the `destroy()` that
213
+ * ends them.
214
+ */
215
+ declare const bindFieldProperties: (schema: readonly FieldSchema[], group: FormGroup, options: {
216
+ injector: Injector;
217
+ onError?: ExpressionErrorPolicy;
218
+ }) => FormBinding;
219
+
220
+ export { bindFieldProperties, createControlSource };
221
+ export type { FieldProperties, FieldSchema, FormBinding };
@@ -0,0 +1,139 @@
1
+ import { EvalOptions, EvalContext } from '@zvenigora/ng-eval-core';
2
+ import { SignalContextSource } from '@zvenigora/ng-eval-signals';
3
+
4
+ /**
5
+ * Coerces an expression's result to the boolean a `visible` rule needs.
6
+ *
7
+ * **JavaScript truthiness, and nothing cleverer** (plan S 3.6). A rule author
8
+ * writing `visible: "country === 'US'"` gets a boolean already; the coercion
9
+ * exists for the ones who write `visible: "country"`.
10
+ *
11
+ * `undefined` is therefore the load-bearing input rather than an edge case.
12
+ * An *empty* field and a *missing* field are indistinguishable here - S 3.4.3:
13
+ * a field key bound to `undefined` is treated as absent, and an empty
14
+ * `FormControl` is the common case - so both arrive as `undefined`, and both
15
+ * mean "not visible".
16
+ *
17
+ * The string `'false'` is **visible**, because it is a non-empty string. That
18
+ * is truthiness rather than parsing, and it is the answer a form-builder UI
19
+ * storing every value as a string will hit. Documented in the README rather
20
+ * than special-cased: a coercion that parsed `'false'` would then owe an
21
+ * answer for `'no'`, `'0'` and `'off'`, none of which JavaScript has one for.
22
+ *
23
+ * Both adapters call this, which is why it is in the core: `/reactive` and
24
+ * `/signals` differ in how they schedule the recompute and not in what the
25
+ * result means (S 3.4.5).
26
+ *
27
+ * @param value - Whatever the expression evaluated to.
28
+ * @returns `true` when the value is truthy.
29
+ */
30
+ declare const toVisible: (value: unknown) => boolean;
31
+ /**
32
+ * Coerces an expression's result to the string a `text` rule needs.
33
+ *
34
+ * `String(value)`, with `null` and `undefined` mapping to `''` (plan S 3.6).
35
+ * Those two are the carve-out and the only one: rendering the literal text
36
+ * `undefined` into a label is the failure this rule exists to prevent, and it
37
+ * is what a bare `String(value)` would do for the most ordinary input a form
38
+ * has - the empty control of S 3.4.3.
39
+ *
40
+ * Every *other* falsy value stringifies normally: `0` is `'0'` and `false` is
41
+ * `'false'`. Mapping all falsy values to `''` would be the obvious way to
42
+ * write this rule wrongly, and it would blank a field whose value is
43
+ * legitimately zero.
44
+ *
45
+ * @param value - Whatever the expression evaluated to.
46
+ * @returns The value as a string, or `''` when it is `null` or `undefined`.
47
+ */
48
+ declare const toText: (value: unknown) => string;
49
+
50
+ /**
51
+ * What a field property does when its expression throws at runtime.
52
+ *
53
+ * - `'throw'` - rethrow, matching `eval-signals`' own default.
54
+ * - `'undefined'` - the property resolves `undefined`, which `toVisible`
55
+ * reads as not visible and `toText` reads as `''`.
56
+ * - a function - called with the error; its return value becomes the
57
+ * property's value.
58
+ *
59
+ * **The default here is `'undefined'`, the opposite of `eval-signals`'**, and
60
+ * the consumer is the reason (plan S 3.4.4). An expression that fails in
61
+ * `eval-signals` was written by the developer reading the stack trace. An
62
+ * expression that fails here may have been typed into a form builder by an
63
+ * end user, and the right response to "the admin wrote a bad rule" is a field
64
+ * that does not render, not an application that throws on every change
65
+ * detection pass. Consumers who want the strict behaviour pass `'throw'`.
66
+ *
67
+ * Resolving that default is a real step and not a formality: `createEvalSignal`
68
+ * does `options?.onError ?? 'throw'`, so forwarding an *absent* policy verbatim
69
+ * inherits `'throw'` - the opposite of this default. The `/reactive` binding
70
+ * substitutes its own before the call.
71
+ *
72
+ * The union is structurally identical to `EvalSignalOptions['onError']`
73
+ * **deliberately**: `/reactive` resolves the default here and forwards the
74
+ * value to `createEvalSignal` with no mapping between the two unions, which is
75
+ * where two near-identical types would drift apart.
76
+ *
77
+ * `SignalContextWriteError` is **not** routed through this, in either adapter,
78
+ * matching `phase-3-plan.md` S 3.6.3. A write violation is static - illegal on
79
+ * every recompute with every dataset - and swallowing it under a default of
80
+ * `'undefined'` would hand every consumer a silent blank for a bug in the
81
+ * rule's own syntax. `createEvalSignal` already bypasses `onError` for it.
82
+ *
83
+ * **Only the type ships in this phase.** A matching `applyErrorPolicy` helper
84
+ * would have no caller: on the `/reactive` path - the only path Phase 4 ships
85
+ * - `createEvalSignal` applies the three cases internally. Its sole consumer
86
+ * is the `/signals` adapter, which is on paper only (S 9), so the helper is
87
+ * deferred to that phase and will be written against this type. Shipping an
88
+ * unexercised code path is what S 9.1 declines to do for the scope-leak
89
+ * containment, and the rule applies to both or to neither.
90
+ */
91
+ type ExpressionErrorPolicy = 'throw' | 'undefined' | ((error: unknown) => unknown);
92
+
93
+ /**
94
+ * Composes one `EvalContext` for one field, out of two live sources.
95
+ *
96
+ * The mechanism is **two lookups, not a joined record** (plan S 3.4.2). The
97
+ * field half is a `createSignalContext` over `fieldSource`; the form half is a
98
+ * second resolver pushed onto the same context's `lookups`. Both close over
99
+ * their source and read it at resolve time, so a key added to either after
100
+ * construction resolves - there is no third object to keep in sync, and no
101
+ * write-through to N contexts.
102
+ *
103
+ * `EvalContext.get` walks `lookups` in order and takes the first
104
+ * non-`undefined`, so the field half is consulted first.
105
+ *
106
+ * One context per field, never one shared across fields (plan S 3.4.1):
107
+ * `get` resolves `scopes` and `original` *before* `lookups`, so anything left
108
+ * on a shared context - an arrow function's leaked scope, say - would be read
109
+ * ahead of every other field's source key of the same name, for the life of
110
+ * the form.
111
+ *
112
+ * **Resolution is live; appearance is not reactive.** An expression that read
113
+ * a then-missing key subscribed to nothing, so the owner of the source calls
114
+ * `invalidate()` when the *key set* changes. A change to a *value* is
115
+ * Angular's job and needs nothing.
116
+ *
117
+ * **Precedence is value-dependent, not permanent.** A field key wins while
118
+ * its value is *present*: `EvalContext.get` treats `undefined` as absent at
119
+ * every step, and the field resolver returns `undefined` both for "no such
120
+ * key" and for "key bound to `undefined`" - so an empty `FormControl`, which
121
+ * is the common case rather than an exotic one, falls through to the form
122
+ * value. Documented as a limitation (plan S 3.4.3): distinguishing the two
123
+ * would need a sentinel threaded through `EvalContext.get`, which is
124
+ * `eval-core`'s and out of this phase's scope.
125
+ *
126
+ * @param formSource - The form-wide keys, shared by every field of the form.
127
+ * @param fieldSource - The field-local keys. These win on a name collision,
128
+ * while their value is not `undefined`.
129
+ * @param options - Passed to `createSignalContext` for **both** halves;
130
+ * configures this context and its resolvers, not the walk.
131
+ * As upstream, `caseInsensitive` corrects identifier keys
132
+ * here but not *property* names - the member visitor reads
133
+ * those off the state's options, so the same options must
134
+ * also reach `simpleEval` / `createState`.
135
+ */
136
+ declare const createFieldContext: (formSource: SignalContextSource, fieldSource: SignalContextSource, options?: EvalOptions) => EvalContext;
137
+
138
+ export { createFieldContext, toText, toVisible };
139
+ export type { ExpressionErrorPolicy };