@jarenjs/app 0.34.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/src/forms.js ADDED
@@ -0,0 +1,325 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The standard form rules — the shipped JSLT rule set that renders
4
+ * any `@jarenjs/forms` view model (`buildFormViewModel`) to vnodes, and
5
+ * the standard action documents that write user input back into the
6
+ * state. Everything both factories return is PLAIN JSON: no functions,
7
+ * no imports from forms — the rules dispatch on the view-model *shape*
8
+ * (JSONPath filter selectors on `control`), which is the whole point of
9
+ * the format stack.
10
+ *
11
+ * Wiring (see the README for the complete walkthrough):
12
+ *
13
+ * view: [...createFormView(), { match: '$', body: [..., { $apply: '$.form' }] }]
14
+ * actions: { ...createFormActions({ dataPointer: '/data' }) }
15
+ * options: { viewModel: (state) => ({ form: buildFormViewModel(model, state.data, { rules }) }) }
16
+ *
17
+ * A DOM control's value is a STRING, and two of these controls carry
18
+ * something else: a select over a non-string enum, and the `json`
19
+ * editor over an arbitrary value. Both round-trip through the JSON text
20
+ * the view model precomputes (`option.key`) or the operator types, and
21
+ * both decode it in a registered event-field extractor —
22
+ * {@link formEventFields}, the format's one sanctioned place for host
23
+ * JavaScript at the DOM boundary (APP-FORMAT §5.4). A host that renders
24
+ * these controls MUST register them.
25
+ *
26
+ * Remaining limitation, documented rather than hidden: a cleared number
27
+ * input writes `null` (which surfaces as a validation error, not a
28
+ * dispatch error).
29
+ */
30
+
31
+ /** The default action names shared by both factories. */
32
+ const DEFAULT_ACTIONS = Object.freeze({
33
+ input: 'form/input',
34
+ check: 'form/check',
35
+ number: 'form/number',
36
+ json: 'form/json',
37
+ add: 'form/add',
38
+ remove: 'form/remove',
39
+ });
40
+
41
+ /**
42
+ * The `$event` field name both JSON-carrying controls request.
43
+ * @see formEventFields
44
+ */
45
+ const JSON_FIELD = 'formJsonValue';
46
+
47
+ /**
48
+ * The event-field extractors the standard form controls need, for
49
+ * `createApp`'s `eventFields` (APP-FORMAT §5.4).
50
+ *
51
+ * One extractor, `formJsonValue`: the control's value parsed as JSON.
52
+ * A select carries `option.key` (the view model's JSON text for the
53
+ * typed enum value) and the `json` editor carries whatever the operator
54
+ * typed. Unparsable text yields `null` rather than throwing, so a
55
+ * half-typed JSON document is a validation problem — visible, fixable —
56
+ * instead of a dispatch error; `json` fields therefore want a schema
57
+ * that rejects `null` if absence is not acceptable.
58
+ *
59
+ * @example
60
+ * createApp(doc, { node, eventFields: { ...formEventFields() } });
61
+ *
62
+ * @returns {Record<string, (event: any) => any>}
63
+ */
64
+ export function formEventFields() {
65
+ return {
66
+ [JSON_FIELD]: (event) => {
67
+ const raw = event?.target?.value;
68
+ if (typeof raw !== 'string' || raw.trim() === '') return null;
69
+ try {
70
+ return JSON.parse(raw);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Options shared by `createFormView` / `createFormActions`.
81
+ * @typedef {Object} FormViewOptions
82
+ * @property {string} [root] - JSONPath of the form view-model node inside
83
+ * the view input document (default `'$.form'`).
84
+ * @property {string} [classPrefix] - CSS class prefix (default `'jaren-form'`).
85
+ * @property {Record<string, string>} [actions] - Overrides for the
86
+ * standard action names (`input`/`check`/`number`/`add`/`remove`).
87
+ * @property {string} [addLabel] - Add-item button text (default `'+'`).
88
+ * @property {string} [removeLabel] - Remove-item button text (default `'×'`).
89
+ * @property {{addItem?: string, removeItem?: string}} [labels] - Accessible
90
+ * names for the two symbol buttons. `@jarenjs/forms`'
91
+ * `formChromeLabels(catalog)` resolves them from a message catalog;
92
+ * the English defaults apply when absent.
93
+ * @property {string} [dataPointer] - (actions) JSON Pointer to the form
94
+ * data inside the app state (default `'/data'`).
95
+ */
96
+
97
+ /**
98
+ * The standard form rule set: a JSLT rule array rendering a
99
+ * `buildFormViewModel` tree. Concatenate it into an app's view
100
+ * stylesheet and `{"$apply": "<root>"}` the form node from a page rule.
101
+ *
102
+ * @param {FormViewOptions} [options]
103
+ * @returns {any[]} A JSLT rule array (plain JSON).
104
+ */
105
+ export function createFormView(options = {}) {
106
+ const root = options.root ?? '$.form';
107
+ const cls = options.classPrefix ?? 'jaren-form';
108
+ const act = { ...DEFAULT_ACTIONS, ...options.actions };
109
+ const labels = { addItem: 'Add item', removeItem: 'Remove item', ...options.labels };
110
+ /** Match any view-model node under `root` with the given control. */
111
+ const ctl = (control) => `${root}..[?@.control == '${control}']`;
112
+
113
+ const disabled = { $not: '$.enabled' };
114
+
115
+ /** The shared field chrome around one control vnode. */
116
+ const field = (control) => ['div', { class: `${cls}-field`, 'data-pointer': '$.pointer' },
117
+ ['label', {},
118
+ '$.label',
119
+ { $if: ['$.required', ['span', { class: `${cls}-required`, 'aria-hidden': 'true' }, ' *']] },
120
+ control,
121
+ ],
122
+ { $if: ['$.description', ['p', { class: `${cls}-description` }, '$.description']] },
123
+ [{ $apply: '$.errors[*]' }],
124
+ { $if: ['$.removable',
125
+ ['button', {
126
+ type: 'button',
127
+ class: `${cls}-remove`,
128
+ // the glyph is decoration; the accessible name is the label
129
+ 'aria-label': labels.removeItem,
130
+ title: labels.removeItem,
131
+ on: { click: { action: act.remove, with: { pointer: '$.pointer' } } },
132
+ }, options.removeLabel ?? '×']] },
133
+ ];
134
+
135
+ // every write binding carries the element flag: the standard actions
136
+ // pick RFC 6902 `replace` for array elements (where `add` would
137
+ // insert) and `add` for object members (set-or-replace)
138
+ const writeWith = { pointer: '$.pointer', element: '$.element' };
139
+
140
+ /** A typed `<input>` control with the standard input binding. */
141
+ const textInput = (type, action) => ['input', {
142
+ type,
143
+ value: '$.value',
144
+ placeholder: '$.placeholder',
145
+ readonly: '$.readOnly',
146
+ disabled,
147
+ on: { input: { action, with: writeWith } },
148
+ }];
149
+
150
+ /**
151
+ * A date-family `<input>`: the text-input binding plus the schema's
152
+ * date bounds as `min`/`max`, so the picker itself refuses an
153
+ * out-of-range date instead of the user finding out on submit. An
154
+ * absent bound evaluates to the empty sequence, which omits the
155
+ * attribute. HTML has no exclusive date bounds, so
156
+ * `formatExclusive*` stays a submit-time check.
157
+ */
158
+ const dateInput = (type, action) => ['input', {
159
+ type,
160
+ value: '$.value',
161
+ placeholder: '$.placeholder',
162
+ readonly: '$.readOnly',
163
+ disabled,
164
+ min: '$.constraints.formatMinimum',
165
+ max: '$.constraints.formatMaximum',
166
+ on: { input: { action, with: writeWith } },
167
+ }];
168
+
169
+ const rules = [
170
+ // the form root: a section holding the top-level fields
171
+ {
172
+ match: root,
173
+ body: ['section', { class: cls },
174
+ { $if: ['$.label', ['h3', { class: `${cls}-title` }, '$.label']] },
175
+ [{ $apply: '$.children[*]' }],
176
+ [{ $apply: '$.items[*]' }],
177
+ ],
178
+ },
179
+ // nested objects: a fieldset group
180
+ {
181
+ match: ctl('object'),
182
+ body: ['fieldset', { class: `${cls}-group`, 'data-pointer': '$.pointer' },
183
+ { $if: ['$.label', ['legend', {}, '$.label']] },
184
+ [{ $apply: '$.children[*]' }],
185
+ [{ $apply: '$.errors[*]' }],
186
+ ],
187
+ },
188
+ // arrays: expanded items plus the add-item button
189
+ {
190
+ match: ctl('array'),
191
+ body: ['fieldset', { class: `${cls}-array`, 'data-pointer': '$.pointer' },
192
+ { $if: ['$.label', ['legend', {}, '$.label']] },
193
+ [{ $apply: '$.items[*]' }],
194
+ { $if: [{ $exists: '$.addValue' },
195
+ ['button', {
196
+ type: 'button',
197
+ class: `${cls}-add`,
198
+ 'aria-label': labels.addItem,
199
+ title: labels.addItem,
200
+ on: { click: { action: act.add, with: { pointer: '$.pointer', value: '$.addValue' } } },
201
+ }, options.addLabel ?? '+']] },
202
+ [{ $apply: '$.errors[*]' }],
203
+ ],
204
+ },
205
+ // one error line per message
206
+ {
207
+ match: `${root}..errors[*]`,
208
+ body: ['p', { class: `${cls}-error`, role: 'alert' }, '$'],
209
+ },
210
+ // select: options are precomputed by the view model, and carry the
211
+ // JSON text of their typed value so the round trip survives the DOM
212
+ {
213
+ match: `${root}..options[*]`,
214
+ body: ['option', { value: '$.key', selected: '$.selected' }, '$.label'],
215
+ },
216
+ {
217
+ match: ctl('select'),
218
+ body: field(['select', {
219
+ disabled,
220
+ on: {
221
+ change: { action: act.json, with: writeWith, event: [JSON_FIELD] },
222
+ },
223
+ }, [{ $apply: '$.options[*]' }]]),
224
+ },
225
+ // boolean: checkbox with the checked binding
226
+ {
227
+ match: ctl('checkbox'),
228
+ body: field(['input', {
229
+ type: 'checkbox',
230
+ checked: '$.value',
231
+ disabled,
232
+ on: { change: { action: act.check, with: writeWith } },
233
+ }]),
234
+ },
235
+ // free text: textarea with the value as its text child
236
+ {
237
+ match: ctl('textarea'),
238
+ body: field(['textarea', {
239
+ placeholder: '$.placeholder',
240
+ readonly: '$.readOnly',
241
+ disabled,
242
+ on: { input: { action: act.input, with: writeWith } },
243
+ }, '$.value']),
244
+ },
245
+ // numbers: coerced by the standard number action
246
+ { match: ctl('number'), body: field(textInput('number', act.number)) },
247
+ // fixed values render as text
248
+ { match: ctl('const'), body: field(['span', { class: `${cls}-const` }, '$.value']) },
249
+ // structured values: a JSON text editor. The view model precomputes
250
+ // the text (`json`), so the control needs no encoder of its own.
251
+ {
252
+ match: ctl('json'),
253
+ body: field(['textarea', {
254
+ class: `${cls}-json`,
255
+ rows: 4,
256
+ spellcheck: 'false',
257
+ readonly: '$.readOnly',
258
+ disabled,
259
+ on: { change: { action: act.json, with: writeWith, event: [JSON_FIELD] } },
260
+ }, '$.json']),
261
+ },
262
+ ];
263
+
264
+ // the text-input family: one rule per control, all through act.input
265
+ for (const type of ['text', 'email', 'url', 'password', 'color']) {
266
+ rules.push({ match: ctl(type), body: field(textInput(type, act.input)) });
267
+ }
268
+ // the date family, which additionally carries its bounds
269
+ for (const type of ['date', 'datetime-local', 'time']) {
270
+ rules.push({ match: ctl(type), body: field(dateInput(type, act.input)) });
271
+ }
272
+ return rules;
273
+ }
274
+
275
+ /**
276
+ * The standard form actions: named query documents that write user
277
+ * input into the form data at `dataPointer + payload.pointer`. RFC 6902
278
+ * `add` is set-or-replace for object members, so untouched (absent)
279
+ * fields are created on first input.
280
+ *
281
+ * @param {FormViewOptions} [options]
282
+ * @returns {Record<string, any>} An `actions` fragment (plain JSON) to
283
+ * spread into an app document.
284
+ */
285
+ export function createFormActions(options = {}) {
286
+ const dataPointer = options.dataPointer ?? '/data';
287
+ const act = { ...DEFAULT_ACTIONS, ...options.actions };
288
+ const target = { $concat: [dataPointer, '$payload.pointer'] };
289
+ // RFC 6902: `add` on an array index INSERTS (shifting later elements);
290
+ // element nodes must be written with `replace` instead. The view-model
291
+ // `element` flag travels through the binding payload.
292
+ const writeOp = { $if: ['$payload.element', 'replace', 'add'] };
293
+ return {
294
+ [act.input]: {
295
+ patch: [{ op: writeOp, path: target, value: '$event.value' }],
296
+ },
297
+ [act.check]: {
298
+ patch: [{ op: writeOp, path: target, value: '$event.checked' }],
299
+ },
300
+ [act.number]: {
301
+ patch: [{
302
+ op: writeOp,
303
+ path: target,
304
+ // a cleared input writes null: a visible validation problem, not
305
+ // a dispatch error
306
+ value: { $if: [{ $ne: ['$event.value', ''] }, { $number: '$event.value' }, null] },
307
+ }],
308
+ },
309
+ // the two JSON-carrying controls (typed select, json editor) share
310
+ // one action: the extractor already produced a JSON value
311
+ [act.json]: {
312
+ patch: [{ op: writeOp, path: target, value: `$event.${JSON_FIELD}` }],
313
+ },
314
+ [act.add]: {
315
+ patch: [{
316
+ op: 'add',
317
+ path: { $concat: [dataPointer, '$payload.pointer', '/-'] },
318
+ value: '$payload.value',
319
+ }],
320
+ },
321
+ [act.remove]: {
322
+ patch: [{ op: 'remove', path: target }],
323
+ },
324
+ };
325
+ }
package/src/index.js ADDED
@@ -0,0 +1,16 @@
1
+ //@ts-check
2
+ /**
3
+ * @file @jarenjs/app — applications as JSON documents: the compiled
4
+ * dispatch loop over @jarenjs/json engines and the @jarenjs/view
5
+ * renderer. See README.md and docs/APP-FORMAT.md.
6
+ */
7
+
8
+ export { createApp } from './app.js';
9
+ export { compileActions, compileSubs } from './actions.js';
10
+ export { createFormView, createFormActions, formEventFields } from './forms.js';
11
+ export { createTaskEffect } from './tasks.js';
12
+ export { createFocusEffect } from './focus.js';
13
+ export { createTransactionLog } from './diagnostics.js';
14
+ export { createSplitterWidget } from './splitter.js';
15
+ export { createDocStore, encodeShare, decodeShare } from './docstore.js';
16
+ export { AppCompileError, AppRuntimeError, HostValueError, toError, APP_CODES } from './errors.js';
@@ -0,0 +1,113 @@
1
+ //@ts-check
2
+ /**
3
+ * @file A reusable drag-splitter widget for a two-pane (or rail|editor|stage)
4
+ * grid. It drives a CSS ratio variable LIVE during a drag — no per-move
5
+ * dispatch, which would flood the transaction log and undo — and commits the
6
+ * ratio on pointer-UP only, plus keyboard resize as an ARIA separator. Every
7
+ * DOM call is guarded so it mounts inertly over a headless stub (there the
8
+ * live drag is browser-verified). The host binds it as a widget and
9
+ * parameterizes the grid/rail selectors, the CSS variable and the commit
10
+ * action, so studio, play and any future two-pane surface share one splitter.
11
+ */
12
+
13
+ /**
14
+ * @param {Object} opts
15
+ * @param {string} opts.action - the app action dispatched with the committed ratio
16
+ * @param {string} [opts.grid='.jstudio'] - selector for the grid element (the host's closest ancestor)
17
+ * @param {string} [opts.rail] - selector for a fixed left rail inside the grid; the ratio
18
+ * spans from the rail's right edge (or the grid's left when absent) to the grid's right
19
+ * @param {string} [opts.cssVar='--js-ratio'] - the CSS custom property the grid reads for the split
20
+ * @param {number} [opts.min=0.1] - the smallest left-pane ratio (also Home)
21
+ * @param {number} [opts.max=0.9] - the largest left-pane ratio (also End)
22
+ * @param {number} [opts.step=0.05] - the arrow-key step
23
+ * @param {number} [opts.fineStep=0.01] - the Shift+arrow step
24
+ * @returns {{ mount: Function, update: Function, unmount: Function }}
25
+ */
26
+ export function createSplitterWidget(opts) {
27
+ const action = opts.action;
28
+ const gridSel = opts.grid ?? '.jstudio';
29
+ const railSel = opts.rail ?? null;
30
+ const cssVar = opts.cssVar ?? '--js-ratio';
31
+ const min = opts.min ?? 0.1;
32
+ const max = opts.max ?? 0.9;
33
+ const step = opts.step ?? 0.05;
34
+ const fineStep = opts.fineStep ?? 0.01;
35
+ const clamp = (r) => Math.min(max, Math.max(min, r));
36
+
37
+ return {
38
+ mount(host, props, emit) {
39
+ // pointermove/up ride the OWNER DOCUMENT for the span of a drag — the
40
+ // robust splitter pattern: the pointer leaves the thin handle at once,
41
+ // so listening on the handle alone (even with capture) drops the drag.
42
+ // Document listeners catch the move everywhere, torn off on pointer-up.
43
+ const doc = host.ownerDocument ?? null;
44
+ const gridOf = () => (typeof host.closest === 'function' ? host.closest(gridSel) : null);
45
+ const applyRatio = (r) => {
46
+ gridOf()?.style?.setProperty?.(cssVar, String(r));
47
+ host.setAttribute?.('aria-valuenow', String(Math.round(r * 100)));
48
+ };
49
+ // pointer x → the left content pane's share of the (post-rail) span
50
+ const ratioAt = (clientX) => {
51
+ const g = gridOf();
52
+ if (g === null) return handle.ratio;
53
+ const rail = railSel !== null && typeof g.querySelector === 'function' ? g.querySelector(railSel) : null;
54
+ const box = g.getBoundingClientRect();
55
+ const left = rail !== null ? rail.getBoundingClientRect().right : box.left;
56
+ if (!(box.right > left)) return handle.ratio;
57
+ return clamp((clientX - left) / (box.right - left));
58
+ };
59
+ const onMove = (e) => {
60
+ if (!handle.dragging) return;
61
+ e.preventDefault?.();
62
+ handle.ratio = ratioAt(e.clientX);
63
+ applyRatio(handle.ratio); // live only — the commit is on pointer-up
64
+ };
65
+ const onUp = () => {
66
+ if (!handle.dragging) return;
67
+ handle.dragging = false;
68
+ doc?.removeEventListener?.('pointermove', onMove);
69
+ doc?.removeEventListener?.('pointerup', onUp);
70
+ emit({ action, with: handle.ratio });
71
+ };
72
+ const onDown = (e) => {
73
+ if (e.button !== undefined && e.button !== 0) return;
74
+ handle.dragging = true;
75
+ e.preventDefault?.();
76
+ doc?.addEventListener?.('pointermove', onMove);
77
+ doc?.addEventListener?.('pointerup', onUp);
78
+ };
79
+ const onKey = (e) => {
80
+ const s = e.shiftKey ? fineStep : step;
81
+ let next = null;
82
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = clamp(handle.ratio - s);
83
+ else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = clamp(handle.ratio + s);
84
+ else if (e.key === 'Home') next = min;
85
+ else if (e.key === 'End') next = max;
86
+ if (next === null) return;
87
+ e.preventDefault?.();
88
+ handle.ratio = next;
89
+ applyRatio(next);
90
+ emit({ action, with: next });
91
+ };
92
+
93
+ const handle = { host, doc, ratio: clamp(Number(props?.ratio ?? 0.5)), dragging: false, applyRatio, onMove, onUp };
94
+ applyRatio(handle.ratio);
95
+ host.addEventListener?.('pointerdown', onDown);
96
+ host.addEventListener?.('keydown', onKey);
97
+ handle.hostListeners = [['pointerdown', onDown], ['keydown', onKey]];
98
+ return handle;
99
+ },
100
+ update(handle, props) {
101
+ const r = clamp(Number(props?.ratio ?? 0.5));
102
+ if (r !== handle.ratio && !handle.dragging) {
103
+ handle.ratio = r;
104
+ handle.applyRatio(r);
105
+ }
106
+ },
107
+ unmount(handle) {
108
+ for (const [type, fn] of handle.hostListeners) handle.host.removeEventListener?.(type, fn);
109
+ handle.doc?.removeEventListener?.('pointermove', handle.onMove);
110
+ handle.doc?.removeEventListener?.('pointerup', handle.onUp);
111
+ },
112
+ };
113
+ }