@panphora/sapjs 0.2.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/LICENSE +21 -0
- package/README.md +350 -0
- package/dist/sap.js +2490 -0
- package/dist/sap.min.js +6 -0
- package/package.json +52 -0
- package/src/actions.js +310 -0
- package/src/carrier.js +128 -0
- package/src/compile.js +91 -0
- package/src/control-serialize.js +85 -0
- package/src/debug.js +0 -0
- package/src/dom.js +182 -0
- package/src/errors.js +190 -0
- package/src/helpers.js +119 -0
- package/src/lint.js +229 -0
- package/src/mount.js +84 -0
- package/src/mutation-bridge.js +195 -0
- package/src/pass.js +472 -0
- package/src/platform.js +78 -0
- package/src/sap.js +178 -0
- package/src/scheduler.js +65 -0
- package/src/scope.js +265 -0
package/src/pass.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// One full pass: rebuild state from the DOM, compute calc: in dependency order,
|
|
2
|
+
// paint write-if-changed, run effects, apply validation. Nothing is retained
|
|
3
|
+
// between passes except the compile cache.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
walkOwned, rowsOf, parseStateDecl, parseTyped, serializeTyped, ensureId, readTransient,
|
|
7
|
+
} from "./dom.js";
|
|
8
|
+
import { compile, run, topoSort } from "./compile.js";
|
|
9
|
+
import { carrierFor } from "./carrier.js";
|
|
10
|
+
import { applyFormat } from "./helpers.js";
|
|
11
|
+
import { NATIVE_BOOLEANS, setBeacon, clearBeacon } from "./errors.js";
|
|
12
|
+
import { withDomMutationPaused } from "./mutation-bridge.js";
|
|
13
|
+
|
|
14
|
+
const NATIVE_BOOLEAN_SET = new Set(NATIVE_BOOLEANS);
|
|
15
|
+
|
|
16
|
+
export function runPass(app, opts = {}) {
|
|
17
|
+
if (opts.breaker) {
|
|
18
|
+
app.diag.error("E26", app.root, {
|
|
19
|
+
problem: "recompute loop halted after 50 passes; recent triggers: " + (app._breakerRing || []).join(", "),
|
|
20
|
+
});
|
|
21
|
+
setBeacon(app.root, "loop");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// The whole pass runs inside one DOM-mutation pause: every engine-derived write
|
|
25
|
+
// (intake strips, id stamps, default materialization, paints, effects, validity,
|
|
26
|
+
// beacons) is vacuumed away from the mutation bridge so it never schedules another
|
|
27
|
+
// pass, and away from undo/autosave so a single user edit collapses to one entry.
|
|
28
|
+
// No-op standalone with no hub/observer present. See mutation-bridge.js.
|
|
29
|
+
withDomMutationPaused(() => runPassBody(app, opts));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runPassBody(app, opts) {
|
|
33
|
+
const root = app.root;
|
|
34
|
+
const rootObj = {};
|
|
35
|
+
const calcs = [];
|
|
36
|
+
const paints = [];
|
|
37
|
+
const effects = [];
|
|
38
|
+
const invalids = [];
|
|
39
|
+
const pending = []; // declared defaults missing from the DOM (candidates to materialize)
|
|
40
|
+
const whenFields = new Set(); // field names a show-when/option visibility verb reads
|
|
41
|
+
app.diag.errors = [];
|
|
42
|
+
|
|
43
|
+
const rootCtx = { root: rootObj, state: rootObj, item: null, ownerEl: root, projection: false };
|
|
44
|
+
|
|
45
|
+
// Read a declared field's value, falling back to its default. When the bare
|
|
46
|
+
// attribute is absent, record the default so it can be materialized to the DOM
|
|
47
|
+
// after intake — but only if a visibility verb references the field, so the CSS
|
|
48
|
+
// floor and show-when can match it before any write. Non-visibility fields are
|
|
49
|
+
// never materialized, keeping this purely additive for existing apps.
|
|
50
|
+
function resolveDefault(el, d) {
|
|
51
|
+
let raw = el.getAttribute(d.name);
|
|
52
|
+
if (raw == null) {
|
|
53
|
+
raw = d.default != null ? d.default : d.type === "num" ? "0" : d.type === "bool" ? "false" : "";
|
|
54
|
+
pending.push({ el, name: d.name, value: serializeTyped(parseTyped(raw, d.type), d.type) });
|
|
55
|
+
}
|
|
56
|
+
return parseTyped(raw, d.type);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readState(el, obj) {
|
|
60
|
+
const decls = parseStateDecl(el.getAttribute("state") || "");
|
|
61
|
+
el._sapStateDecls = decls;
|
|
62
|
+
for (const d of decls) {
|
|
63
|
+
if (d.type === "string" && NATIVE_BOOLEAN_SET.has(d.name) && typeof el[d.name] === "boolean") {
|
|
64
|
+
obj[d.name] = el.hasAttribute(d.name); // presence semantics (e.g. <details open>)
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (d.transient) {
|
|
68
|
+
obj[d.name] = readTransient(el, d); // runtime-only; never serialized
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
obj[d.name] = resolveDefault(el, d);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readBindInto(el, ctx) {
|
|
76
|
+
if (!el.hasAttribute("bind")) return;
|
|
77
|
+
const field = el.getAttribute("bind");
|
|
78
|
+
const owner = ctx.item || ctx.state;
|
|
79
|
+
const c = carrierFor(el);
|
|
80
|
+
if (c.kind === "radio") {
|
|
81
|
+
if (!(field in owner)) owner[field] = "";
|
|
82
|
+
if (el.checked) owner[field] = el.value;
|
|
83
|
+
} else {
|
|
84
|
+
owner[field] = c.read();
|
|
85
|
+
}
|
|
86
|
+
if (el.hasAttribute("transient")) {
|
|
87
|
+
el.removeAttribute("value"); // the live property carries it; the file never does
|
|
88
|
+
el.removeAttribute("checked");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function collectEl(el, ctx) {
|
|
93
|
+
for (const attr of el.attributes) {
|
|
94
|
+
const name = attr.name;
|
|
95
|
+
const val = attr.value;
|
|
96
|
+
if (name.startsWith("calc:")) {
|
|
97
|
+
calcs.push({ owner: ctx.item || ctx.state, ownerEl: ctx.ownerEl, name: name.slice(5), entry: compile(val), el, ctx });
|
|
98
|
+
} else if (name === "text" || name.startsWith("text:")) {
|
|
99
|
+
paints.push({ el, kind: "text", fmt: name.includes(":") ? name.slice(5) : null, entry: compile(val), ctx });
|
|
100
|
+
} else if (name === "show") {
|
|
101
|
+
paints.push({ el, kind: "show", entry: compile(val), ctx });
|
|
102
|
+
} else if (name.startsWith("attr:")) {
|
|
103
|
+
paints.push({ el, kind: "attr", arg: name.slice(5), entry: compile(val), ctx });
|
|
104
|
+
} else if (name.startsWith("class:")) {
|
|
105
|
+
paints.push({ el, kind: "class", arg: name.slice(6), entry: compile(val), ctx });
|
|
106
|
+
} else if (name.startsWith("css:")) {
|
|
107
|
+
paints.push({ el, kind: "css", arg: name.slice(4), entry: compile(val), ctx });
|
|
108
|
+
} else if (name === "effect") {
|
|
109
|
+
effects.push({ el, entry: compile(val, true), ctx });
|
|
110
|
+
} else if (name === "invalid") {
|
|
111
|
+
invalids.push({ el, entry: compile(val), ctx });
|
|
112
|
+
} else if (name.startsWith("show-when:") || name.startsWith("option:")) {
|
|
113
|
+
const field = name.slice(name.indexOf(":") + 1);
|
|
114
|
+
whenFields.add(field);
|
|
115
|
+
paints.push({ el, kind: "when", match: "show", field, values: val.split("|"), ctx });
|
|
116
|
+
} else if (name.startsWith("hide-when:")) {
|
|
117
|
+
const field = name.slice(name.indexOf(":") + 1);
|
|
118
|
+
whenFields.add(field);
|
|
119
|
+
paints.push({ el, kind: "when", match: "hide", field, values: val.split("|"), ctx });
|
|
120
|
+
} else if (name.startsWith("option-not:")) {
|
|
121
|
+
const field = name.slice(name.indexOf(":") + 1);
|
|
122
|
+
whenFields.add(field);
|
|
123
|
+
paints.push({ el, kind: "when", match: "show-not", field, values: val.split("|"), ctx });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (ctx.projection && el.hasAttribute("bind")) {
|
|
127
|
+
paints.push({ el, kind: "projection", field: el.getAttribute("bind"), ctx });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function defineRowMeta(rowObj, rowEl, i) {
|
|
132
|
+
ensureId(rowEl, "row");
|
|
133
|
+
Object.defineProperty(rowObj, "$key", { value: rowEl.id, enumerable: false });
|
|
134
|
+
Object.defineProperty(rowObj, "$index", { value: i, enumerable: false });
|
|
135
|
+
Object.defineProperty(rowObj, "$el", { value: rowEl, enumerable: false });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function enterScope(el, obj, ctx) {
|
|
139
|
+
readState(el, obj);
|
|
140
|
+
el._sapScope = obj;
|
|
141
|
+
if (!ctx.projection && el.hasAttribute("bind")) readBindInto(el, ctx);
|
|
142
|
+
collectEl(el, ctx);
|
|
143
|
+
if (el.hasAttribute("items")) handleItems(el, obj, ctx);
|
|
144
|
+
else walkScope(el, obj, ctx);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function walkScope(scopeEl, scopeObj, ctx) {
|
|
148
|
+
walkOwned(scopeEl, (el, boundary) => {
|
|
149
|
+
if (boundary) {
|
|
150
|
+
if (el.hasAttribute("scope")) handleScope(el, scopeObj, ctx);
|
|
151
|
+
else if (el.hasAttribute("items")) {
|
|
152
|
+
collectEl(el, ctx);
|
|
153
|
+
handleItems(el, scopeObj, ctx);
|
|
154
|
+
} else if (el.hasAttribute("detail")) handleDetail(el, scopeObj, ctx);
|
|
155
|
+
} else {
|
|
156
|
+
if (!ctx.projection) readBindInto(el, ctx);
|
|
157
|
+
collectEl(el, ctx);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function handleScope(el, parentObj, ctx) {
|
|
163
|
+
const name = el.getAttribute("scope");
|
|
164
|
+
const childObj = {};
|
|
165
|
+
parentObj[name] = childObj;
|
|
166
|
+
const cctx = { root: ctx.root, state: childObj, item: null, ownerEl: el, projection: ctx.projection };
|
|
167
|
+
enterScope(el, childObj, cctx);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleItems(listEl, parentObj, ctx) {
|
|
171
|
+
const name = listEl.getAttribute("items");
|
|
172
|
+
const arr = [];
|
|
173
|
+
parentObj[name] = arr;
|
|
174
|
+
listEl._sapList = arr;
|
|
175
|
+
rowsOf(listEl).forEach((rowEl, i) => {
|
|
176
|
+
const rowObj = {};
|
|
177
|
+
defineRowMeta(rowObj, rowEl, i);
|
|
178
|
+
const rctx = { root: ctx.root, state: ctx.state, item: rowObj, ownerEl: rowEl, projection: ctx.projection };
|
|
179
|
+
enterScope(rowEl, rowObj, rctx);
|
|
180
|
+
arr.push(rowObj);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolvePathValue(path, ctx) {
|
|
185
|
+
const segs = String(path).split(".");
|
|
186
|
+
let o = ctx.state;
|
|
187
|
+
for (const s of segs) o = o == null ? undefined : o[s];
|
|
188
|
+
if (o === undefined) {
|
|
189
|
+
o = ctx.root;
|
|
190
|
+
for (const s of segs) o = o == null ? undefined : o[s];
|
|
191
|
+
}
|
|
192
|
+
return o;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function handleDetail(el, parentObj, ctx) {
|
|
196
|
+
// A detail's own state= declarations name the key field; it lives on the
|
|
197
|
+
// enclosing scope so the row's set: and the panel's key expr see one field.
|
|
198
|
+
for (const d of parseStateDecl(el.getAttribute("state") || "")) {
|
|
199
|
+
if (!(d.name in parentObj)) parentObj[d.name] = resolveDefault(el, d);
|
|
200
|
+
}
|
|
201
|
+
el._sapScope = parentObj;
|
|
202
|
+
const spec = el.getAttribute("detail") || "";
|
|
203
|
+
const m = /^([\w.]+)\s+by\s+(.+)$/.exec(spec);
|
|
204
|
+
if (!m) {
|
|
205
|
+
el.hidden = true;
|
|
206
|
+
el._sapDetailRow = null;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const listPath = m[1];
|
|
210
|
+
const keyExpr = m[2].trim();
|
|
211
|
+
const arr = resolvePathValue(listPath, ctx);
|
|
212
|
+
let keyVal;
|
|
213
|
+
try {
|
|
214
|
+
keyVal = run(compile(keyExpr), { state: parentObj, item: ctx.item, el, root: ctx.root });
|
|
215
|
+
} catch {
|
|
216
|
+
keyVal = undefined;
|
|
217
|
+
}
|
|
218
|
+
const row = Array.isArray(arr) ? arr.find((r) => r && String(r.$key) === String(keyVal)) : null;
|
|
219
|
+
el._sapDetailRow = row ? row.$el : null;
|
|
220
|
+
if (!row) {
|
|
221
|
+
if (!el.hidden) el.hidden = true;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (el.hidden) el.hidden = false;
|
|
225
|
+
const dctx = { root: ctx.root, state: parentObj, item: row, ownerEl: row.$el, projection: true };
|
|
226
|
+
collectEl(el, dctx);
|
|
227
|
+
walkScope(el, row, dctx);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- intake ---
|
|
231
|
+
enterScope(root, rootObj, rootCtx);
|
|
232
|
+
|
|
233
|
+
// --- materialize defaults a visibility verb references, so the CSS floor
|
|
234
|
+
// (hyperclayjs optionVisibility) and our own show-when can match them before any
|
|
235
|
+
// write. Not counted as paints: this completes the file, it is not a user edit.
|
|
236
|
+
// Only whenFields are touched, so apps without show-when/option see no DOM change.
|
|
237
|
+
// (The whole-pass mutation pause keeps these writes off undo/autosave.) ---
|
|
238
|
+
if (pending.length && whenFields.size) {
|
|
239
|
+
for (const w of pending) {
|
|
240
|
+
if (whenFields.has(w.name) && w.el.getAttribute(w.name) == null) {
|
|
241
|
+
w.el.setAttribute(w.name, w.value);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- compute calc: deepest scopes first, topo within each scope ---
|
|
247
|
+
const groups = new Map();
|
|
248
|
+
for (const c of calcs) {
|
|
249
|
+
let g = groups.get(c.owner);
|
|
250
|
+
if (!g) {
|
|
251
|
+
g = { ownerEl: c.ownerEl, list: [] };
|
|
252
|
+
groups.set(c.owner, g);
|
|
253
|
+
}
|
|
254
|
+
g.list.push(c);
|
|
255
|
+
}
|
|
256
|
+
const ordered = [...groups.values()].sort((a, b) => depth(b.ownerEl) - depth(a.ownerEl));
|
|
257
|
+
for (const g of ordered) {
|
|
258
|
+
let sorted;
|
|
259
|
+
try {
|
|
260
|
+
sorted = topoSort(g.list);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
app.diag.error("E07", g.list[0].el, { problem: err.message });
|
|
263
|
+
sorted = g.list;
|
|
264
|
+
}
|
|
265
|
+
for (const c of sorted) {
|
|
266
|
+
try {
|
|
267
|
+
c.owner[c.name] = run(c.entry, { state: c.ctx.state, item: c.ctx.item, el: c.el, root: c.ctx.root });
|
|
268
|
+
clearBeacon(c.el);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
c.owner[c.name] = undefined;
|
|
271
|
+
app.diag.error("E24", c.el, { attr: "calc:" + c.name, expr: c.entry.src, problem: String(err.message) });
|
|
272
|
+
setBeacon(c.el, err.sapCode || "E24");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- paint ---
|
|
278
|
+
// Engine-derived output (text/hidden/attr/class/css), effects, and validity
|
|
279
|
+
// complete the file from current state; they are not user edits. The whole-pass
|
|
280
|
+
// mutation pause (runPass) keeps these writes off the bridge, undo, and autosave,
|
|
281
|
+
// so one user edit does not spawn N undoable/dirtying derived writes: undo restores
|
|
282
|
+
// the source attribute and we re-derive on undo/redo (platform.js), so a single
|
|
283
|
+
// undo reverts the action and its derived effects together.
|
|
284
|
+
let writes = 0;
|
|
285
|
+
for (const p of paints) writes += paintOne(p, app);
|
|
286
|
+
for (const e of effects) runEffect(e, app);
|
|
287
|
+
for (const iv of invalids) runInvalid(iv);
|
|
288
|
+
|
|
289
|
+
app._state = rootObj;
|
|
290
|
+
app._stats = {
|
|
291
|
+
fields: Object.keys(rootObj).filter((k) => typeof rootObj[k] !== "function").length,
|
|
292
|
+
calcs: calcs.length,
|
|
293
|
+
paints: paints.length,
|
|
294
|
+
lists: groups.size,
|
|
295
|
+
writes,
|
|
296
|
+
};
|
|
297
|
+
app._lastPass = { trigger: opts.trigger || "refresh", writes };
|
|
298
|
+
if (app._debug) {
|
|
299
|
+
console.log(`sap pass · trigger: ${app._lastPass.trigger} · writes ${writes}/${paints.length}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function paintAttrName(p) {
|
|
304
|
+
if (p.kind === "text") return p.fmt ? "text:" + p.fmt : "text";
|
|
305
|
+
if (p.kind === "attr") return "attr:" + p.arg;
|
|
306
|
+
if (p.kind === "class") return "class:" + p.arg;
|
|
307
|
+
if (p.kind === "css") return "css:" + p.arg;
|
|
308
|
+
if (p.kind === "when") return (p.match === "hide" ? "hide-when:" : p.match === "show-not" ? "option-not:" : "show-when:") + p.field;
|
|
309
|
+
return p.kind;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Walk self + ancestors for an element carrying `field`, matching optionVisibility's
|
|
313
|
+
// "inside a matching scope" semantics. notMode flips to "present but not a value".
|
|
314
|
+
function nearestMode(el, field, values, notMode) {
|
|
315
|
+
let cur = el;
|
|
316
|
+
while (cur && cur.nodeType === 1) {
|
|
317
|
+
if (cur.hasAttribute && cur.hasAttribute(field)) {
|
|
318
|
+
const inList = values.includes(cur.getAttribute(field));
|
|
319
|
+
if (notMode ? !inList : inList) return true;
|
|
320
|
+
}
|
|
321
|
+
cur = cur.parentElement;
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function applyProjection(el, v) {
|
|
327
|
+
if (el === el.ownerDocument.activeElement) return 0; // user is typing here
|
|
328
|
+
const c = carrierFor(el);
|
|
329
|
+
const cur = c.read();
|
|
330
|
+
const nv = v == null ? "" : v;
|
|
331
|
+
if (String(cur) !== String(nv)) {
|
|
332
|
+
c.write(nv, { silent: true });
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function paintOne(p, app) {
|
|
339
|
+
if (p.kind === "projection") {
|
|
340
|
+
return applyProjection(p.el, p.ctx.item ? p.ctx.item[p.field] : "");
|
|
341
|
+
}
|
|
342
|
+
if (p.kind === "when") {
|
|
343
|
+
return applyPaint(p, null); // visibility is read from the DOM, not an expression
|
|
344
|
+
}
|
|
345
|
+
const ctx = { state: p.ctx.state, item: p.ctx.item, el: p.el, root: p.ctx.root };
|
|
346
|
+
try {
|
|
347
|
+
const value = run(p.entry, ctx);
|
|
348
|
+
const n = applyPaint(p, value);
|
|
349
|
+
clearBeacon(p.el);
|
|
350
|
+
return n;
|
|
351
|
+
} catch (err) {
|
|
352
|
+
app.diag.error(err.sapCode === "E22" ? "E22" : "E24", p.el, {
|
|
353
|
+
attr: paintAttrName(p),
|
|
354
|
+
expr: p.entry && p.entry.src,
|
|
355
|
+
problem: String(err.message),
|
|
356
|
+
});
|
|
357
|
+
setBeacon(p.el, err.sapCode || "E24");
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function applyPaint(p, value) {
|
|
363
|
+
const el = p.el;
|
|
364
|
+
switch (p.kind) {
|
|
365
|
+
case "text": {
|
|
366
|
+
const s = p.fmt != null ? applyFormat(p.fmt, value) : value == null ? "" : String(value);
|
|
367
|
+
if (el.textContent !== s) {
|
|
368
|
+
el.textContent = s;
|
|
369
|
+
return 1;
|
|
370
|
+
}
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
case "show": {
|
|
374
|
+
const hide = !value;
|
|
375
|
+
if (el.hidden !== hide) {
|
|
376
|
+
el.hidden = hide;
|
|
377
|
+
return 1;
|
|
378
|
+
}
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
case "when": {
|
|
382
|
+
const ov = typeof window !== "undefined" && window.hyperclay && window.hyperclay.optionVisibility;
|
|
383
|
+
if (ov && ov._started) {
|
|
384
|
+
// The CSS floor owns visibility now; clear any hidden we set pre-floor.
|
|
385
|
+
if (el._sapWhen && el.hidden) { el.hidden = false; el._sapWhen = false; return 1; }
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
let visible;
|
|
389
|
+
if (p.match === "hide") visible = !nearestMode(el, p.field, p.values, false);
|
|
390
|
+
else if (p.match === "show-not") visible = nearestMode(el, p.field, p.values, true);
|
|
391
|
+
else visible = nearestMode(el, p.field, p.values, false);
|
|
392
|
+
el._sapWhen = true;
|
|
393
|
+
const hide = !visible;
|
|
394
|
+
if (el.hidden !== hide) {
|
|
395
|
+
el.hidden = hide;
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
case "attr": {
|
|
401
|
+
const a = p.arg;
|
|
402
|
+
if (NATIVE_BOOLEAN_SET.has(a)) {
|
|
403
|
+
const has = el.hasAttribute(a);
|
|
404
|
+
if (value && !has) {
|
|
405
|
+
el.setAttribute(a, "");
|
|
406
|
+
return 1;
|
|
407
|
+
}
|
|
408
|
+
if (!value && has) {
|
|
409
|
+
el.removeAttribute(a);
|
|
410
|
+
return 1;
|
|
411
|
+
}
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
const s = typeof value === "boolean" ? String(value) : value == null ? "" : String(value);
|
|
415
|
+
if (el.getAttribute(a) !== s) {
|
|
416
|
+
el.setAttribute(a, s);
|
|
417
|
+
return 1;
|
|
418
|
+
}
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
case "class": {
|
|
422
|
+
const on = !!value;
|
|
423
|
+
if (el.classList.contains(p.arg) !== on) {
|
|
424
|
+
el.classList.toggle(p.arg, on);
|
|
425
|
+
return 1;
|
|
426
|
+
}
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
case "css": {
|
|
430
|
+
const s = value == null ? "" : String(value);
|
|
431
|
+
if (el.style.getPropertyValue("--" + p.arg) !== s) {
|
|
432
|
+
el.style.setProperty("--" + p.arg, s);
|
|
433
|
+
return 1;
|
|
434
|
+
}
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
437
|
+
default:
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function runEffect(e, app) {
|
|
443
|
+
try {
|
|
444
|
+
run(e.entry, { state: e.ctx.state, item: e.ctx.item, el: e.el, root: e.ctx.root });
|
|
445
|
+
clearBeacon(e.el);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
app.diag.error("E24", e.el, { attr: "effect", expr: e.entry && e.entry.src, problem: String(err.message) });
|
|
448
|
+
setBeacon(e.el, "E24");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function runInvalid(iv) {
|
|
453
|
+
let msg = "";
|
|
454
|
+
try {
|
|
455
|
+
const v = run(iv.entry, { state: iv.ctx.state, item: iv.ctx.item, el: iv.el, root: iv.ctx.root });
|
|
456
|
+
msg = typeof v === "string" ? v : "";
|
|
457
|
+
} catch {
|
|
458
|
+
msg = "";
|
|
459
|
+
}
|
|
460
|
+
if (typeof iv.el.setCustomValidity === "function") iv.el.setCustomValidity(msg);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function depth(el) {
|
|
464
|
+
let d = 0;
|
|
465
|
+
let c = el;
|
|
466
|
+
while (c) {
|
|
467
|
+
if (c.hasAttribute && c.hasAttribute("sap")) break;
|
|
468
|
+
c = c.parentElement;
|
|
469
|
+
d++;
|
|
470
|
+
}
|
|
471
|
+
return d;
|
|
472
|
+
}
|
package/src/platform.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Optional Hyperclay integration. Everything here degrades to a no-op when
|
|
2
|
+
// window.hyperclay is absent, so Sap runs standalone in any HTML file. When the
|
|
3
|
+
// platform is present, Sap rides its live-sync, undo, and autosave for free.
|
|
4
|
+
|
|
5
|
+
// Region-aware save guard. When the platform's region model is present, a control
|
|
6
|
+
// inside a no-save (or frozen) region must not write save-bytes the platform would
|
|
7
|
+
// strip anyway. The control stays fully live and reactive; only the serialization
|
|
8
|
+
// mirror is skipped. Standalone (no region API) returns false, so Sap mirrors as
|
|
9
|
+
// before — same "use the platform feature if present, degrade otherwise" pattern
|
|
10
|
+
// as the undo and optionVisibility handshakes.
|
|
11
|
+
export function regionSkipsSave(el) {
|
|
12
|
+
const region = typeof window !== "undefined" && window.hyperclay && window.hyperclay.region;
|
|
13
|
+
if (!region || typeof region.resolveRegionPolicy !== "function" || typeof region.skipForPolicy !== "function") {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return region.skipForPolicy(region.resolveRegionPolicy(el), undefined, ["no-save", "freeze"]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Themed confirmation dialog. When the platform's consent() is present (the
|
|
20
|
+
// `everything` preset bundles both dialogs and sap), a confirm-gated action uses
|
|
21
|
+
// it instead of the native window.confirm box. consent(msg, yesCallback) fires the
|
|
22
|
+
// callback synchronously on confirm and rejects on cancel, so Sap passes its action
|
|
23
|
+
// as the callback and never goes async. Returns null standalone, so Sap falls back
|
|
24
|
+
// to window.confirm — same degrade-otherwise pattern as the region/undo handshakes.
|
|
25
|
+
export function platformConsent() {
|
|
26
|
+
const h = typeof window !== "undefined" && window.hyperclay;
|
|
27
|
+
return h && typeof h.consent === "function" ? h.consent : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function batch(label, fn) {
|
|
31
|
+
if (typeof fn !== "function") throw new Error("Sap.batch(label, fn): fn must be a function");
|
|
32
|
+
const u = typeof window !== "undefined" && window.hyperclay && window.hyperclay.undo;
|
|
33
|
+
if (u && u.flush) u.flush();
|
|
34
|
+
const r = fn();
|
|
35
|
+
if (r && typeof r.then === "function") throw new Error("Sap.batch fn must be synchronous (it returned a promise)");
|
|
36
|
+
if (u && u.commitCaptured) u.commitCaptured(label);
|
|
37
|
+
return r;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Wire live-sync and undo to a synchronous refresh, and re-mount if a morph
|
|
41
|
+
// replaces the [sap] element wholesale (delegated listeners would die silently).
|
|
42
|
+
export function installBridges(runtime) {
|
|
43
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
44
|
+
|
|
45
|
+
const refreshConnected = () => {
|
|
46
|
+
let disconnected = false;
|
|
47
|
+
for (const app of runtime.apps()) {
|
|
48
|
+
if (app.root.isConnected) runtime.runNow(app, "platform");
|
|
49
|
+
else disconnected = true;
|
|
50
|
+
}
|
|
51
|
+
// A morph may have replaced a root wholesale: drop the dead app records (so they
|
|
52
|
+
// don't pile up in order) and re-mount to pick up the replacement.
|
|
53
|
+
if (disconnected) {
|
|
54
|
+
runtime.pruneDisconnected();
|
|
55
|
+
runtime.remountIfPresent();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// M2: livesync-applied must refresh synchronously inside the pause window.
|
|
60
|
+
document.addEventListener("hyperclay:livesync-applied", refreshConnected);
|
|
61
|
+
|
|
62
|
+
// Undo/redo replay attributes with no events; re-derive after.
|
|
63
|
+
const undo = window.hyperclay && window.hyperclay.undo;
|
|
64
|
+
if (undo && typeof undo.on === "function") {
|
|
65
|
+
undo.on("undo", refreshConnected);
|
|
66
|
+
undo.on("redo", refreshConnected);
|
|
67
|
+
} else {
|
|
68
|
+
// Lazy handshake: the platform may load after us. The hub dispatches
|
|
69
|
+
// hyperclay:mutation-ready non-bubbling on `document`, so listen there (not window).
|
|
70
|
+
document.addEventListener("hyperclay:mutation-ready", () => {
|
|
71
|
+
const u = window.hyperclay && window.hyperclay.undo;
|
|
72
|
+
if (u && typeof u.on === "function") {
|
|
73
|
+
u.on("undo", refreshConnected);
|
|
74
|
+
u.on("redo", refreshConnected);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|