@jarenjs/forms 0.73.0 → 0.75.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.
package/README.md CHANGED
@@ -189,7 +189,7 @@ These two externals are the whole vocabulary: any other free name in a rule is a
189
189
 
190
190
  ### Array item templates
191
191
 
192
- A rule on an array item template (`/lines/-/amount`) compiles **once** and evaluates **per element** of the actual array, binding `$value`/`$pointer` per index — results are keyed by the expanded pointer (`/lines/2/amount`). That compiled-once/dispatch-per-node generalization now exists as the [`@jarenjs/json/jslt`](../json/docs/JSLT-FORMAT.md) `$apply` engine: a future forms computed-view layer can generalize `x-form.computed` into schema-dispatched view-model stylesheets without changing forms' validator-independent boundary.
192
+ A rule on an array item template (`/lines/-/amount`) compiles **once** and evaluates **per element** of the actual array, binding `$value`/`$pointer` per index — results are keyed by the expanded pointer (`/lines/2/amount`). Rendering uses [`@jarenjs/json/jslt`](../json/docs/JSLT-FORMAT.md) rules over the composed view model. Composition retains the model, data and session cursors together; see the addressing measurement below.
193
193
 
194
194
  ### Schema literals in rules
195
195
 
@@ -383,6 +383,39 @@ Form data keeps plain JSON semantics — an untouched field is *absent*, not an
383
383
 
384
384
  `buildFormViewModel(model, data, options)` composes everything above — the field tree, the current data, per-field validation and `x-form` rule state — into **one plain-JSON render tree**: the "computed view" layer this README promised. Each node carries `pointer`, `label`, `control`, `value` (`x-form.computed` wins, `null` when absent), precomputed select `options` (with `selected`), localized `errors`, `enabled`, and the write discipline flags (`element`: array elements must be written with RFC 6902 `replace`, since `add` inserts; `removable`; `addValue` from `createItemValue`). Rule-hidden fields are *excluded* — a renderer cannot leak hidden data by accident. Array item templates expand per data element with concrete pointers (`/lines/2/amount`), matching the pointer keys of `evaluateFormRules` and `validateAllFields`. A short tuple's `addValue` comes from its next prefix slot; the tail item template supplies starters only after the prefix is filled.
385
385
 
386
+ The composition walk carries the current **raw data cursor** and the
387
+ session's **initial cursor** down beside each field. It reads one pointer
388
+ suffix per descent instead of starting from the document root per field;
389
+ computed container values do not replace the raw cursor used by children.
390
+ Missing baseline ancestors stay missing, preserving dirty-state detection.
391
+
392
+ `$fold` can implement a second cursor with typed segments, but this alone
393
+ is not a reason to replace the composer. The executable experiment in
394
+ `benchmark/forms-composition.js` compares a fold-based JSLT rule with root
395
+ pointer getters and carried cursors on the same expanded fields. Its
396
+ parity tests include escaped keys, array indexes and missing ancestors.
397
+
398
+ <!--fact:forms.addressing-->
399
+
400
+ Measured on v24.19.0, linux/x64; median milliseconds per expanded-field projection. Compilation and field expansion are excluded from all contenders.
401
+
402
+ | Depth | Nodes | Root pointers | Carried cursors | Fold stylesheet |
403
+ |---|---|---|---|---|
404
+ | 1 | 33 | 0.0043 | 0.0040 | 0.0817 |
405
+ | 8 | 136 | 0.0314 | 0.0101 | 0.6325 |
406
+ | 24 | 536 | 0.2811 | 0.0319 | 5.1716 |
407
+
408
+ <!--/fact-->
409
+
410
+ The shallow timings are close and do not justify a universal speedup
411
+ claim; deeper trees benefit from carrying cursors. The fold stylesheet loses on every
412
+ fixture even with expansion excluded. These measurements cover addressing
413
+ only, not the complete session/validation/render pipeline. The production
414
+ composer therefore keeps the JS walk. A complete stylesheet replacement
415
+ still needs evidence for array-template expansion, hidden fields, enum
416
+ values, errors and session state; first-class functions are not a
417
+ prerequisite.
418
+
386
419
  ```javascript
387
420
  const model = buildFormModel(schema);
388
421
  const rules = compileFormRules(model);
@@ -478,3 +511,17 @@ Every subpath a consumer can import, derived from the manifest by
478
511
  ## Development
479
512
 
480
513
  Unit tests live in `test/forms/` at the repository root. See the repository [README](../../README.md) for the full Jaren documentation, and the [ROADMAP](../../docs/ROADMAP.md) for planned forms work (rule dependency memoization, hidden-field pruning on submit, computed views through JSLT).
514
+
515
+ ## Form chrome policy
516
+
517
+ `formChromeLabels(catalog)` supplies `addItem`, `removeItem` and
518
+ `jsonPlaceholder`, with English fallbacks and translations in every
519
+ locale pack. Pass them to `createFormView({ labels })`; the JSON hint is
520
+ both an empty-editor placeholder and an accessible description. A label
521
+ beginning with `$` remains literal text.
522
+
523
+ The add/remove glyphs and required marker are decoration, not message
524
+ keys: customize `addLabel`, `removeLabel` and `requiredMarker` on the
525
+ stylesheet. Required controls carry `aria-required`; the decorative
526
+ marker stays hidden from assistive technology. JSON syntax and the name
527
+ “JSON” retain their wire spelling; instructions around them are localized.
@@ -47,18 +47,19 @@ export declare function formatDisplayName(catalog: Readonly<Record<string, (para
47
47
  export declare function renderFormsMessage(catalog: Readonly<Record<string, (params: object, error?: object) => string>> | undefined, msgid: string, params: object): string;
48
48
  /**
49
49
  * The localized chrome strings a form renderer needs: the accessible
50
- * names of the array add/remove buttons.
50
+ * names of the array add/remove buttons and the JSON editor's hint.
51
51
  *
52
52
  * The stylesheet that renders a form is plain JSON built once, so it
53
53
  * cannot look anything up at render time — the host resolves these and
54
54
  * hands them to `createFormView`. Kept next to the error catalog on
55
55
  * purpose: one keyspace, one parity gate across every locale pack.
56
56
  * @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog] - A compiled catalog, or undefined for English
57
- * @returns {{addItem: string, removeItem: string}}
57
+ * @returns {{addItem: string, removeItem: string, jsonPlaceholder: string}}
58
58
  * @example
59
59
  * createFormView({ labels: formChromeLabels(catalogs[locale]) });
60
60
  */
61
61
  export declare function formChromeLabels(catalog?: Readonly<Record<string, (params: object, error?: object) => string>>): {
62
62
  addItem: string;
63
63
  removeItem: string;
64
+ jsonPlaceholder: string;
64
65
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/forms",
3
3
  "private": false,
4
- "version": "0.73.0",
4
+ "version": "0.75.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -47,9 +47,9 @@
47
47
  "prepack": "npm run build:types"
48
48
  },
49
49
  "dependencies": {
50
- "@jarenjs/core": "^0.73.0",
51
- "@jarenjs/formats": "^0.73.0",
52
- "@jarenjs/json": "^0.73.0",
53
- "@jarenjs/validate": "^0.73.0"
50
+ "@jarenjs/core": "^0.75.0",
51
+ "@jarenjs/formats": "^0.75.0",
52
+ "@jarenjs/json": "^0.75.0",
53
+ "@jarenjs/validate": "^0.75.0"
54
54
  }
55
55
  }
package/src/messages.js CHANGED
@@ -63,6 +63,7 @@ export const formsMessagesEn = {
63
63
  // message like every other string an operator can hear.
64
64
  'form/addItem': 'Add item',
65
65
  'form/removeItem': 'Remove item',
66
+ 'form/jsonPlaceholder': 'Enter a JSON value',
66
67
  };
67
68
 
68
69
  /** The compiled built-in English catalog (module-level singleton). */
@@ -127,14 +128,14 @@ export function renderFormsMessage(catalog, msgid, params) {
127
128
 
128
129
  /**
129
130
  * The localized chrome strings a form renderer needs: the accessible
130
- * names of the array add/remove buttons.
131
+ * names of the array add/remove buttons and the JSON editor's hint.
131
132
  *
132
133
  * The stylesheet that renders a form is plain JSON built once, so it
133
134
  * cannot look anything up at render time — the host resolves these and
134
135
  * hands them to `createFormView`. Kept next to the error catalog on
135
136
  * purpose: one keyspace, one parity gate across every locale pack.
136
137
  * @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog] - A compiled catalog, or undefined for English
137
- * @returns {{addItem: string, removeItem: string}}
138
+ * @returns {{addItem: string, removeItem: string, jsonPlaceholder: string}}
138
139
  * @example
139
140
  * createFormView({ labels: formChromeLabels(catalogs[locale]) });
140
141
  */
@@ -142,6 +143,7 @@ export function formChromeLabels(catalog = undefined) {
142
143
  return {
143
144
  addItem: renderFormsMessage(catalog, 'form/addItem', {}),
144
145
  removeItem: renderFormsMessage(catalog, 'form/removeItem', {}),
146
+ jsonPlaceholder: renderFormsMessage(catalog, 'form/jsonPlaceholder', {}),
145
147
  };
146
148
  }
147
149
 
package/src/viewmodel.js CHANGED
@@ -184,7 +184,7 @@ export function buildFormViewModel(model, data, options = {}) {
184
184
  const session = options.session !== undefined
185
185
  ? compileSession(options.session, data)
186
186
  : null;
187
- const root = buildNode(model, '', data, ruleState, fieldErrors, false, false, session);
187
+ const root = buildNode(model, '', data, ruleState, fieldErrors, false, false, session, session?.initial);
188
188
  if (root !== null && session !== null) {
189
189
  root.session = {
190
190
  dirty: session.dirtyPaths.length > 0,
@@ -286,19 +286,19 @@ function pointerId(prefix, pointer) {
286
286
  * @param {string} pointer - The concrete RFC 6901 pointer of this node
287
287
  * (segments encoded with `encodeJSONPointerSegment`, the walk
288
288
  * convention shared with the model, rule and validation pointers).
289
- * @param {any} data - The form data root.
289
+ * @param {any} raw - The data cursor at this field (before computed values).
290
290
  * @param {Record<string, any>} ruleState
291
291
  * @param {Record<string, any>} fieldErrors
292
292
  * @param {boolean} element
293
293
  * @param {boolean} removable
294
294
  * @param {ReturnType<typeof compileSession> | null} [session]
295
+ * @param {any} [initialValue] - The baseline cursor at this field.
295
296
  * @returns {FormViewNode|null}
296
297
  */
297
- function buildNode(field, pointer, data, ruleState, fieldErrors, element, removable, session = null) {
298
+ function buildNode(field, pointer, raw, ruleState, fieldErrors, element, removable, session = null, initialValue = undefined) {
298
299
  const rs = ruleState[pointer];
299
300
  if (rs !== undefined && rs.visible === false) return null;
300
301
 
301
- const raw = getValueAtPointer(data, pointer);
302
302
  let value = field.kind === 'const' ? field.constValue : raw;
303
303
  if (rs !== undefined && rs.computed !== undefined) value = rs.computed;
304
304
 
@@ -369,7 +369,6 @@ function buildNode(field, pointer, data, ruleState, fieldErrors, element, remova
369
369
  if (session.hasInitial) {
370
370
  // presence-aware: adding or removing a member whose value is
371
371
  // null is a membership change, so it is dirty
372
- const initialValue = getValueAtPointer(session.initial, pointer);
373
372
  node.dirty = (initialValue === undefined) !== (raw === undefined)
374
373
  || (initialValue !== undefined && !equalsJson(initialValue, raw));
375
374
  }
@@ -381,9 +380,11 @@ function buildNode(field, pointer, data, ruleState, fieldErrors, element, remova
381
380
  if (field.children !== null && field.children !== undefined) {
382
381
  const children = [];
383
382
  for (const child of field.children) {
383
+ const segment = `/${encodeJSONPointerSegment(child.key)}`;
384
384
  const built = buildNode(
385
- child, `${pointer}/${encodeJSONPointerSegment(child.key)}`, data, ruleState,
386
- fieldErrors, false, false, session);
385
+ child, pointer + segment, getValueAtPointer(raw, segment), ruleState,
386
+ fieldErrors, false, false, session,
387
+ session?.hasInitial ? getValueAtPointer(initialValue, segment) : undefined);
387
388
  if (built !== null) children.push(built);
388
389
  }
389
390
  node.children = children;
@@ -404,7 +405,8 @@ function buildNode(field, pointer, data, ruleState, fieldErrors, element, remova
404
405
  // still valid is `minItems`' answer to give, not this layer's.
405
406
  const removable = !isTupleSlot || i === array.length - 1;
406
407
  const built = buildNode(
407
- template, `${pointer}/${i}`, data, ruleState, fieldErrors, true, removable, session);
408
+ template, `${pointer}/${i}`, array[i], ruleState, fieldErrors, true, removable, session,
409
+ session?.hasInitial ? getValueAtPointer(initialValue, `/${i}`) : undefined);
408
410
  if (built !== null) items.push(built);
409
411
  }
410
412
  node.items = items;