xhtmlx 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.
- package/LICENSE +21 -0
- package/README.md +384 -0
- package/package.json +62 -0
- package/xhtmlx.d.ts +351 -0
- package/xhtmlx.js +2825 -0
- package/xhtmlx.min.js +2 -0
- package/xhtmlx.min.js.map +1 -0
package/xhtmlx.js
ADDED
|
@@ -0,0 +1,2825 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xhtmlx.js — Declarative HTML attributes for REST API driven UIs.
|
|
3
|
+
*
|
|
4
|
+
* Like htmx, but the server returns JSON and xhtmlx renders UI client-side
|
|
5
|
+
* using templates.
|
|
6
|
+
*
|
|
7
|
+
* Zero dependencies. Single file. IIFE pattern.
|
|
8
|
+
*
|
|
9
|
+
* trigger → request → receive JSON → render template with data → swap into DOM
|
|
10
|
+
*/
|
|
11
|
+
(function () {
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Configuration
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
var config = {
|
|
18
|
+
debug: false,
|
|
19
|
+
defaultSwapMode: "innerHTML",
|
|
20
|
+
indicatorClass: "xh-indicator",
|
|
21
|
+
requestClass: "xh-request",
|
|
22
|
+
errorClass: "xh-error",
|
|
23
|
+
batchThreshold: 100, // xh-each arrays above this size use rAF batching
|
|
24
|
+
defaultErrorTemplate: null, // Global fallback error template URL
|
|
25
|
+
defaultErrorTarget: null, // Global fallback error target CSS selector
|
|
26
|
+
templatePrefix: "", // Prefix prepended to all xh-template URLs
|
|
27
|
+
apiPrefix: "", // Prefix prepended to all REST verb URLs
|
|
28
|
+
uiVersion: null // Current UI version identifier (any string)
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Internal state
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** WeakMap<Element, ElementState> — per-element bookkeeping */
|
|
36
|
+
var elementStates = new WeakMap();
|
|
37
|
+
|
|
38
|
+
/** Map<string, Promise<string>> — external template cache (URL → HTML) */
|
|
39
|
+
var templateCache = new Map();
|
|
40
|
+
|
|
41
|
+
/** Set<string> — currently resolving template URLs (circular detection) */
|
|
42
|
+
// (per-chain stacks are passed as arrays instead of a global set)
|
|
43
|
+
|
|
44
|
+
/** Generation counter per element for discarding stale responses */
|
|
45
|
+
var generationMap = new WeakMap();
|
|
46
|
+
|
|
47
|
+
/** Map<string, {data: string, timestamp: number}> — response cache (verb:url → body) */
|
|
48
|
+
var responseCache = new Map();
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Default CSS injection
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
function injectDefaultCSS() {
|
|
54
|
+
var id = "xhtmlx-default-css";
|
|
55
|
+
if (document.getElementById(id)) return;
|
|
56
|
+
var style = document.createElement("style");
|
|
57
|
+
style.id = id;
|
|
58
|
+
style.textContent =
|
|
59
|
+
".xh-indicator { opacity: 0; transition: opacity 200ms ease-in; }\n" +
|
|
60
|
+
".xh-request .xh-indicator, .xh-request.xh-indicator { opacity: 1; }\n" +
|
|
61
|
+
".xh-added { }\n" +
|
|
62
|
+
".xh-settled { }\n" +
|
|
63
|
+
".xh-invalid { border-color: #ef4444; }\n";
|
|
64
|
+
document.head.appendChild(style);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// DataContext
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Holds the JSON data for the current rendering scope and a reference to its
|
|
73
|
+
* parent context so that templates can walk up the chain.
|
|
74
|
+
*
|
|
75
|
+
* @param {*} data – The JSON payload for this level.
|
|
76
|
+
* @param {DataContext} parent – Enclosing context (null at root).
|
|
77
|
+
* @param {number|null} index – Current iteration index for xh-each.
|
|
78
|
+
*/
|
|
79
|
+
function DataContext(data, parent, index) {
|
|
80
|
+
this.data = data != null ? data : {};
|
|
81
|
+
this.parent = parent || null;
|
|
82
|
+
this.index = index != null ? index : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve a dotted path against this context.
|
|
87
|
+
*
|
|
88
|
+
* Special variables:
|
|
89
|
+
* $index – iteration index
|
|
90
|
+
* $parent – jump to parent context, continue resolving remainder
|
|
91
|
+
* $root – jump to root context, continue resolving remainder
|
|
92
|
+
*
|
|
93
|
+
* If the key is not found locally, we walk up the parent chain.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} path – e.g. "user.name", "$parent.title", "$index"
|
|
96
|
+
* @returns {*} resolved value or undefined
|
|
97
|
+
*/
|
|
98
|
+
DataContext.prototype.resolve = function (path) {
|
|
99
|
+
if (path == null || path === "") return undefined;
|
|
100
|
+
|
|
101
|
+
// -- transform pipe support: "price | currency" --------------------------
|
|
102
|
+
if (path.indexOf(" | ") !== -1) {
|
|
103
|
+
var pipeParts = path.split(" | ");
|
|
104
|
+
var rawValue = this.resolve(pipeParts[0].trim());
|
|
105
|
+
for (var p = 1; p < pipeParts.length; p++) {
|
|
106
|
+
var transformName = pipeParts[p].trim();
|
|
107
|
+
if (transforms[transformName]) {
|
|
108
|
+
rawValue = transforms[transformName](rawValue);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return rawValue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
var parts = path.split(".");
|
|
115
|
+
|
|
116
|
+
// --- special variables ---------------------------------------------------
|
|
117
|
+
if (parts[0] === "$index") {
|
|
118
|
+
if (parts.length === 1) return this.index;
|
|
119
|
+
// $index doesn't have sub-properties
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (parts[0] === "$parent") {
|
|
124
|
+
if (!this.parent) return undefined;
|
|
125
|
+
if (parts.length === 1) return this.parent.data;
|
|
126
|
+
return this.parent.resolve(parts.slice(1).join("."));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (parts[0] === "$root") {
|
|
130
|
+
var root = this;
|
|
131
|
+
while (root.parent) root = root.parent;
|
|
132
|
+
if (parts.length === 1) return root.data;
|
|
133
|
+
return root.resolve(parts.slice(1).join("."));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- local lookup --------------------------------------------------------
|
|
137
|
+
var value = resolveDot(this.data, parts);
|
|
138
|
+
if (value !== undefined) return value;
|
|
139
|
+
|
|
140
|
+
// --- walk parent chain ---------------------------------------------------
|
|
141
|
+
if (this.parent) return this.parent.resolve(path);
|
|
142
|
+
|
|
143
|
+
return undefined;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve a dotted path against a plain object.
|
|
148
|
+
* @param {Object} obj
|
|
149
|
+
* @param {string[]} parts
|
|
150
|
+
* @returns {*}
|
|
151
|
+
*/
|
|
152
|
+
function resolveDot(obj, parts) {
|
|
153
|
+
var cur = obj;
|
|
154
|
+
for (var i = 0; i < parts.length; i++) {
|
|
155
|
+
if (cur == null || typeof cur !== "object") return undefined;
|
|
156
|
+
if (!(parts[i] in cur)) return undefined;
|
|
157
|
+
cur = cur[parts[i]];
|
|
158
|
+
}
|
|
159
|
+
return cur;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// MutableDataContext — reactive wrapper around DataContext
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A DataContext subclass that supports live reactivity.
|
|
168
|
+
* When a field is changed via set(), all subscribers for that path are notified.
|
|
169
|
+
*
|
|
170
|
+
* @param {*} data – The JSON payload for this level.
|
|
171
|
+
* @param {DataContext} parent – Enclosing context (null at root).
|
|
172
|
+
* @param {number|null} index – Current iteration index for xh-each.
|
|
173
|
+
*/
|
|
174
|
+
function MutableDataContext(data, parent, index) {
|
|
175
|
+
DataContext.call(this, data, parent, index);
|
|
176
|
+
this._subscribers = {}; // path -> [callback]
|
|
177
|
+
}
|
|
178
|
+
MutableDataContext.prototype = Object.create(DataContext.prototype);
|
|
179
|
+
MutableDataContext.prototype.constructor = MutableDataContext;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Set a value at the given dotted path, creating intermediate objects as needed.
|
|
183
|
+
* Notifies all subscribers for the given path.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} path – e.g. "user.name"
|
|
186
|
+
* @param {*} value – The new value.
|
|
187
|
+
*/
|
|
188
|
+
MutableDataContext.prototype.set = function(path, value) {
|
|
189
|
+
var parts = path.split(".");
|
|
190
|
+
var obj = this.data;
|
|
191
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
192
|
+
if (obj[parts[i]] == null) obj[parts[i]] = {};
|
|
193
|
+
obj = obj[parts[i]];
|
|
194
|
+
}
|
|
195
|
+
obj[parts[parts.length - 1]] = value;
|
|
196
|
+
this._notify(path);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Subscribe to changes on a given path.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} path – The dotted path to watch.
|
|
203
|
+
* @param {Function} callback – Called when the value at path changes.
|
|
204
|
+
*/
|
|
205
|
+
MutableDataContext.prototype.subscribe = function(path, callback) {
|
|
206
|
+
if (!this._subscribers[path]) this._subscribers[path] = [];
|
|
207
|
+
this._subscribers[path].push(callback);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Notify all subscribers for a given path.
|
|
212
|
+
* @param {string} path
|
|
213
|
+
*/
|
|
214
|
+
MutableDataContext.prototype._notify = function(path) {
|
|
215
|
+
var subs = this._subscribers[path];
|
|
216
|
+
if (subs) {
|
|
217
|
+
for (var i = 0; i < subs.length; i++) subs[i]();
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Interpolation — {{field}} replacement
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
var INTERP_RE = /\{\{([^}]+)\}\}/g;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Replace all {{field}} tokens in a string using the given DataContext.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} str – Source string.
|
|
231
|
+
* @param {DataContext} ctx – Data context for resolution.
|
|
232
|
+
* @param {boolean} uriEnc – If true, URI-encode each substituted value.
|
|
233
|
+
* @returns {string}
|
|
234
|
+
*/
|
|
235
|
+
function interpolate(str, ctx, uriEnc) {
|
|
236
|
+
return str.replace(INTERP_RE, function (_match, path) {
|
|
237
|
+
var val = ctx.resolve(path.trim());
|
|
238
|
+
if (val === undefined) {
|
|
239
|
+
if (config.debug) {
|
|
240
|
+
console.warn("[xhtmlx] unresolved interpolation: {{" + path + "}}");
|
|
241
|
+
}
|
|
242
|
+
return "";
|
|
243
|
+
}
|
|
244
|
+
var s = String(val);
|
|
245
|
+
return uriEnc ? encodeURIComponent(s) : s;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Walk a DOM tree and interpolate {{field}} tokens in text nodes and
|
|
251
|
+
* non-xh-* attributes. Leaves xh-* attribute values untouched so they
|
|
252
|
+
* can be processed later with the correct data context (e.g. per-item
|
|
253
|
+
* context inside xh-each).
|
|
254
|
+
*
|
|
255
|
+
* @param {Element} root
|
|
256
|
+
* @param {DataContext} ctx
|
|
257
|
+
*/
|
|
258
|
+
function interpolateDOM(root, ctx) {
|
|
259
|
+
var walker = document.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
|
|
260
|
+
var textNodes = [];
|
|
261
|
+
while (walker.nextNode()) {
|
|
262
|
+
textNodes.push(walker.currentNode);
|
|
263
|
+
}
|
|
264
|
+
for (var t = 0; t < textNodes.length; t++) {
|
|
265
|
+
var node = textNodes[t];
|
|
266
|
+
if (INTERP_RE.test(node.nodeValue)) {
|
|
267
|
+
node.nodeValue = interpolate(node.nodeValue, ctx, false);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Interpolate non-xh-* attributes on all elements
|
|
272
|
+
var elements = root.querySelectorAll("*");
|
|
273
|
+
for (var e = 0; e < elements.length; e++) {
|
|
274
|
+
var attrs = elements[e].attributes;
|
|
275
|
+
for (var a = 0; a < attrs.length; a++) {
|
|
276
|
+
var name = attrs[a].name;
|
|
277
|
+
// Skip xh-* attributes — they are processed by directives/executeRequest
|
|
278
|
+
if (name.indexOf("xh-") === 0) continue;
|
|
279
|
+
if (INTERP_RE.test(attrs[a].value)) {
|
|
280
|
+
attrs[a].value = interpolate(attrs[a].value, ctx, false);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Template loading (external fetch + cache, inline <template>, self)
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Fetch an external template by URL, with deduplication and caching.
|
|
292
|
+
* @param {string} url
|
|
293
|
+
* @returns {Promise<string>}
|
|
294
|
+
*/
|
|
295
|
+
function fetchTemplate(url) {
|
|
296
|
+
// Prepend template prefix (for UI versioning)
|
|
297
|
+
var fetchUrl = config.templatePrefix ? config.templatePrefix + url : url;
|
|
298
|
+
// Cache by prefixed URL so version switches get fresh templates
|
|
299
|
+
if (templateCache.has(fetchUrl)) return templateCache.get(fetchUrl);
|
|
300
|
+
var promise = fetch(fetchUrl).then(function (res) {
|
|
301
|
+
if (!res.ok) throw new Error("Template fetch failed: " + fetchUrl + " (" + res.status + ")");
|
|
302
|
+
return res.text();
|
|
303
|
+
});
|
|
304
|
+
templateCache.set(fetchUrl, promise);
|
|
305
|
+
return promise;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Resolve the template HTML for an element.
|
|
310
|
+
*
|
|
311
|
+
* Priority:
|
|
312
|
+
* 1. xh-template attribute (external URL)
|
|
313
|
+
* 2. Inline <template> child
|
|
314
|
+
* 3. null (self-binding — element itself is the target)
|
|
315
|
+
*
|
|
316
|
+
* @param {Element} el
|
|
317
|
+
* @param {string[]} templateStack – URLs already being resolved (circular check)
|
|
318
|
+
* @returns {Promise<{html: string|null, isExternal: boolean}>}
|
|
319
|
+
*/
|
|
320
|
+
function resolveTemplate(el, templateStack) {
|
|
321
|
+
var url = el.getAttribute("xh-template");
|
|
322
|
+
|
|
323
|
+
// -- external template ----------------------------------------------------
|
|
324
|
+
if (url) {
|
|
325
|
+
if (templateStack.indexOf(url) !== -1) {
|
|
326
|
+
console.error("[xhtmlx] circular template reference detected: " + url);
|
|
327
|
+
return Promise.reject(new Error("Circular template: " + url));
|
|
328
|
+
}
|
|
329
|
+
var newStack = templateStack.concat(url);
|
|
330
|
+
return fetchTemplate(url).then(function (html) {
|
|
331
|
+
return { html: html, isExternal: true, templateStack: newStack };
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// -- inline <template> ----------------------------------------------------
|
|
336
|
+
var tpl = el.querySelector(":scope > template");
|
|
337
|
+
if (tpl) {
|
|
338
|
+
return Promise.resolve({
|
|
339
|
+
html: tpl.innerHTML,
|
|
340
|
+
isExternal: false,
|
|
341
|
+
templateStack: templateStack
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// -- self-binding ---------------------------------------------------------
|
|
346
|
+
return Promise.resolve({
|
|
347
|
+
html: null,
|
|
348
|
+
isExternal: false,
|
|
349
|
+
templateStack: templateStack
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Directive processing
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
var REST_VERBS = ["xh-get", "xh-post", "xh-put", "xh-delete", "xh-patch"];
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Returns the REST verb attribute on an element, if any.
|
|
361
|
+
* @param {Element} el
|
|
362
|
+
* @returns {{verb: string, url: string}|null}
|
|
363
|
+
*/
|
|
364
|
+
function getRestVerb(el) {
|
|
365
|
+
for (var i = 0; i < REST_VERBS.length; i++) {
|
|
366
|
+
var url = el.getAttribute(REST_VERBS[i]);
|
|
367
|
+
if (url != null) {
|
|
368
|
+
var method = REST_VERBS[i].replace("xh-", "").toUpperCase();
|
|
369
|
+
return { verb: method, url: url };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Apply data-binding directives to a single element (xh-text, xh-html,
|
|
377
|
+
* xh-attr-*, xh-if, xh-unless). Does NOT handle xh-each — that is done
|
|
378
|
+
* separately because it clones elements.
|
|
379
|
+
*
|
|
380
|
+
* @param {Element} el
|
|
381
|
+
* @param {DataContext} ctx
|
|
382
|
+
* @returns {boolean} false if the element was removed by xh-if/xh-unless
|
|
383
|
+
*/
|
|
384
|
+
function applyBindings(el, ctx) {
|
|
385
|
+
// -- xh-show ----------------------------------------------------------------
|
|
386
|
+
var showAttr = el.getAttribute("xh-show");
|
|
387
|
+
if (showAttr != null) {
|
|
388
|
+
var sval = ctx.resolve(showAttr);
|
|
389
|
+
el.style.display = sval ? "" : "none";
|
|
390
|
+
if (ctx instanceof MutableDataContext) {
|
|
391
|
+
(function(field, element, context) {
|
|
392
|
+
context.subscribe(field, function() {
|
|
393
|
+
var newVal = context.resolve(field);
|
|
394
|
+
element.style.display = newVal ? "" : "none";
|
|
395
|
+
});
|
|
396
|
+
})(showAttr, el, ctx);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// -- xh-hide ----------------------------------------------------------------
|
|
401
|
+
var hideAttr = el.getAttribute("xh-hide");
|
|
402
|
+
if (hideAttr != null) {
|
|
403
|
+
var hdval = ctx.resolve(hideAttr);
|
|
404
|
+
el.style.display = hdval ? "none" : "";
|
|
405
|
+
if (ctx instanceof MutableDataContext) {
|
|
406
|
+
(function(field, element, context) {
|
|
407
|
+
context.subscribe(field, function() {
|
|
408
|
+
var newVal = context.resolve(field);
|
|
409
|
+
element.style.display = newVal ? "none" : "";
|
|
410
|
+
});
|
|
411
|
+
})(hideAttr, el, ctx);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// -- xh-if ----------------------------------------------------------------
|
|
416
|
+
var ifAttr = el.getAttribute("xh-if");
|
|
417
|
+
if (ifAttr != null) {
|
|
418
|
+
var val = ctx.resolve(ifAttr);
|
|
419
|
+
if (!val) {
|
|
420
|
+
el.remove();
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// -- xh-unless ------------------------------------------------------------
|
|
426
|
+
var unlessAttr = el.getAttribute("xh-unless");
|
|
427
|
+
if (unlessAttr != null) {
|
|
428
|
+
var uval = ctx.resolve(unlessAttr);
|
|
429
|
+
if (uval) {
|
|
430
|
+
el.remove();
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// -- xh-text --------------------------------------------------------------
|
|
436
|
+
var textAttr = el.getAttribute("xh-text");
|
|
437
|
+
if (textAttr != null) {
|
|
438
|
+
var tv = ctx.resolve(textAttr);
|
|
439
|
+
el.textContent = tv != null ? String(tv) : "";
|
|
440
|
+
if (ctx instanceof MutableDataContext) {
|
|
441
|
+
(function(field, element, context) {
|
|
442
|
+
context.subscribe(field, function() {
|
|
443
|
+
var newVal = context.resolve(field);
|
|
444
|
+
element.textContent = newVal != null ? String(newVal) : "";
|
|
445
|
+
});
|
|
446
|
+
})(textAttr, el, ctx);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// -- xh-html --------------------------------------------------------------
|
|
451
|
+
var htmlAttr = el.getAttribute("xh-html");
|
|
452
|
+
if (htmlAttr != null) {
|
|
453
|
+
var hv = ctx.resolve(htmlAttr);
|
|
454
|
+
el.innerHTML = hv != null ? String(hv) : "";
|
|
455
|
+
if (ctx instanceof MutableDataContext) {
|
|
456
|
+
(function(field, element, context) {
|
|
457
|
+
context.subscribe(field, function() {
|
|
458
|
+
var newVal = context.resolve(field);
|
|
459
|
+
element.innerHTML = newVal != null ? String(newVal) : "";
|
|
460
|
+
});
|
|
461
|
+
})(htmlAttr, el, ctx);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// -- xh-attr-* ------------------------------------------------------------
|
|
466
|
+
var attrs = el.attributes;
|
|
467
|
+
for (var i = attrs.length - 1; i >= 0; i--) {
|
|
468
|
+
var aName = attrs[i].name;
|
|
469
|
+
if (aName.indexOf("xh-attr-") === 0) {
|
|
470
|
+
var targetAttr = aName.slice(8); // after "xh-attr-"
|
|
471
|
+
var aval = ctx.resolve(attrs[i].value);
|
|
472
|
+
if (aval != null) {
|
|
473
|
+
el.setAttribute(targetAttr, String(aval));
|
|
474
|
+
}
|
|
475
|
+
if (ctx instanceof MutableDataContext) {
|
|
476
|
+
(function(field, tAttr, element, context) {
|
|
477
|
+
context.subscribe(field, function() {
|
|
478
|
+
var newVal = context.resolve(field);
|
|
479
|
+
if (newVal != null) {
|
|
480
|
+
element.setAttribute(tAttr, String(newVal));
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
})(attrs[i].value, targetAttr, el, ctx);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// -- xh-model ---------------------------------------------------------------
|
|
489
|
+
var modelAttr = el.getAttribute("xh-model");
|
|
490
|
+
if (modelAttr != null) {
|
|
491
|
+
var mv = ctx.resolve(modelAttr);
|
|
492
|
+
var tag = el.tagName.toLowerCase();
|
|
493
|
+
var type = (el.getAttribute("type") || "").toLowerCase();
|
|
494
|
+
|
|
495
|
+
if (tag === "select") {
|
|
496
|
+
// Set the matching option as selected
|
|
497
|
+
var options = el.options;
|
|
498
|
+
for (var s = 0; s < options.length; s++) {
|
|
499
|
+
options[s].selected = (options[s].value === mv);
|
|
500
|
+
}
|
|
501
|
+
} else if (type === "checkbox") {
|
|
502
|
+
el.checked = !!mv;
|
|
503
|
+
} else if (type === "radio") {
|
|
504
|
+
el.checked = (el.value === mv);
|
|
505
|
+
} else if (tag === "textarea") {
|
|
506
|
+
el.value = mv != null ? String(mv) : "";
|
|
507
|
+
} else {
|
|
508
|
+
// text, email, number, hidden, etc.
|
|
509
|
+
el.value = mv != null ? String(mv) : "";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Live reactivity: when the user edits an xh-model input, call ctx.set()
|
|
513
|
+
if (ctx instanceof MutableDataContext) {
|
|
514
|
+
(function(field, element, context) {
|
|
515
|
+
var eventName = (type === "checkbox" || type === "radio" || tag === "select") ? "change" : "input";
|
|
516
|
+
element.addEventListener(eventName, function() {
|
|
517
|
+
var newValue;
|
|
518
|
+
if (type === "checkbox") {
|
|
519
|
+
newValue = element.checked;
|
|
520
|
+
} else {
|
|
521
|
+
newValue = element.value;
|
|
522
|
+
}
|
|
523
|
+
context.set(field, newValue);
|
|
524
|
+
});
|
|
525
|
+
})(modelAttr, el, ctx);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// -- xh-class-* -------------------------------------------------------------
|
|
530
|
+
for (var c = attrs.length - 1; c >= 0; c--) {
|
|
531
|
+
var cName = attrs[c].name;
|
|
532
|
+
if (cName.indexOf("xh-class-") === 0) {
|
|
533
|
+
var className = cName.slice(9); // after "xh-class-"
|
|
534
|
+
var cval = ctx.resolve(attrs[c].value);
|
|
535
|
+
if (cval) {
|
|
536
|
+
el.classList.add(className);
|
|
537
|
+
} else {
|
|
538
|
+
el.classList.remove(className);
|
|
539
|
+
}
|
|
540
|
+
if (ctx instanceof MutableDataContext) {
|
|
541
|
+
(function(field, clsName, element, context) {
|
|
542
|
+
context.subscribe(field, function() {
|
|
543
|
+
var newVal = context.resolve(field);
|
|
544
|
+
if (newVal) {
|
|
545
|
+
element.classList.add(clsName);
|
|
546
|
+
} else {
|
|
547
|
+
element.classList.remove(clsName);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
})(attrs[c].value, className, el, ctx);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// -- custom directives -------------------------------------------------------
|
|
556
|
+
for (var cd = 0; cd < customDirectives.length; cd++) {
|
|
557
|
+
var directive = customDirectives[cd];
|
|
558
|
+
var cdVal = el.getAttribute(directive.name);
|
|
559
|
+
if (cdVal != null) {
|
|
560
|
+
directive.handler(el, cdVal, ctx);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Process xh-each on an element. Clones the element for each item in the
|
|
569
|
+
* array, applies bindings, and recursively processes each clone.
|
|
570
|
+
*
|
|
571
|
+
* @param {Element} el
|
|
572
|
+
* @param {DataContext} ctx
|
|
573
|
+
*/
|
|
574
|
+
function processEach(el, ctx) {
|
|
575
|
+
var eachAttr = el.getAttribute("xh-each");
|
|
576
|
+
if (eachAttr == null) return false;
|
|
577
|
+
|
|
578
|
+
var arr = ctx.resolve(eachAttr);
|
|
579
|
+
if (!Array.isArray(arr)) {
|
|
580
|
+
if (config.debug) {
|
|
581
|
+
console.warn("[xhtmlx] xh-each: '" + eachAttr + "' did not resolve to an array");
|
|
582
|
+
}
|
|
583
|
+
el.remove();
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
var parent = el.parentNode;
|
|
588
|
+
if (!parent) return true;
|
|
589
|
+
|
|
590
|
+
// Remove the xh-each attribute from the template element so clones don't
|
|
591
|
+
// re-trigger iteration.
|
|
592
|
+
el.removeAttribute("xh-each");
|
|
593
|
+
|
|
594
|
+
var fragment = document.createDocumentFragment();
|
|
595
|
+
|
|
596
|
+
var renderItem = function (item, idx) {
|
|
597
|
+
var clone = el.cloneNode(true);
|
|
598
|
+
// Mark clone so renderTemplate's second pass doesn't rebind with wrong context
|
|
599
|
+
clone.setAttribute("data-xh-each-item", "");
|
|
600
|
+
var ItemCtxClass = (ctx instanceof MutableDataContext) ? MutableDataContext : DataContext;
|
|
601
|
+
var itemCtx = new ItemCtxClass(item, ctx, idx);
|
|
602
|
+
applyBindings(clone, itemCtx);
|
|
603
|
+
// Process children bindings
|
|
604
|
+
processBindingsInTree(clone, itemCtx);
|
|
605
|
+
// Recursively process for nested REST verb elements
|
|
606
|
+
processNode(clone, itemCtx);
|
|
607
|
+
fragment.appendChild(clone);
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
if (arr.length > config.batchThreshold) {
|
|
611
|
+
// For large arrays we still render synchronously for simplicity but
|
|
612
|
+
// could be enhanced with rAF batching in a future version.
|
|
613
|
+
for (var i = 0; i < arr.length; i++) {
|
|
614
|
+
renderItem(arr[i], i);
|
|
615
|
+
}
|
|
616
|
+
} else {
|
|
617
|
+
for (var j = 0; j < arr.length; j++) {
|
|
618
|
+
renderItem(arr[j], j);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
parent.insertBefore(fragment, el);
|
|
623
|
+
parent.removeChild(el);
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Walk a subtree applying bindings (xh-text, xh-html, xh-attr-*, xh-if,
|
|
629
|
+
* xh-unless, xh-each) to every element. This is used after template
|
|
630
|
+
* rendering.
|
|
631
|
+
*
|
|
632
|
+
* @param {Element} root
|
|
633
|
+
* @param {DataContext} ctx
|
|
634
|
+
*/
|
|
635
|
+
function processBindingsInTree(root, ctx) {
|
|
636
|
+
// Collect elements that need processing. We snapshot because DOM
|
|
637
|
+
// mutations (xh-each, xh-if) can modify the live list.
|
|
638
|
+
var elements = Array.prototype.slice.call(root.querySelectorAll("*"));
|
|
639
|
+
for (var i = 0; i < elements.length; i++) {
|
|
640
|
+
var el = elements[i];
|
|
641
|
+
// Skip if already detached from DOM
|
|
642
|
+
if (!el.parentNode) continue;
|
|
643
|
+
|
|
644
|
+
// xh-each must be handled before other bindings on the same element
|
|
645
|
+
if (el.hasAttribute("xh-each")) {
|
|
646
|
+
processEach(el, ctx);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Apply simple bindings
|
|
651
|
+
applyBindings(el, ctx);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
// Trigger parsing
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Determine the default trigger event for an element.
|
|
661
|
+
* @param {Element} el
|
|
662
|
+
* @returns {string}
|
|
663
|
+
*/
|
|
664
|
+
function defaultTrigger(el) {
|
|
665
|
+
var tag = el.tagName.toLowerCase();
|
|
666
|
+
if (tag === "form") return "submit";
|
|
667
|
+
if (tag === "input" || tag === "select" || tag === "textarea") return "change";
|
|
668
|
+
return "click"; // buttons, links, divs, etc.
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Parse the xh-trigger attribute into an array of trigger spec objects.
|
|
673
|
+
*
|
|
674
|
+
* Syntax examples:
|
|
675
|
+
* "click"
|
|
676
|
+
* "click once"
|
|
677
|
+
* "keyup changed delay:300ms"
|
|
678
|
+
* "load"
|
|
679
|
+
* "every 5s"
|
|
680
|
+
* "revealed"
|
|
681
|
+
* "click from:#other-button"
|
|
682
|
+
*
|
|
683
|
+
* @param {string} raw
|
|
684
|
+
* @returns {Object[]}
|
|
685
|
+
*/
|
|
686
|
+
function parseTrigger(raw) {
|
|
687
|
+
if (!raw || !raw.trim()) return [];
|
|
688
|
+
|
|
689
|
+
// Multiple triggers can be comma-separated
|
|
690
|
+
var parts = raw.split(",");
|
|
691
|
+
var specs = [];
|
|
692
|
+
|
|
693
|
+
for (var p = 0; p < parts.length; p++) {
|
|
694
|
+
var tokens = parts[p].trim().split(/\s+/);
|
|
695
|
+
if (tokens.length === 0 || tokens[0] === "") continue;
|
|
696
|
+
|
|
697
|
+
var spec = {
|
|
698
|
+
event: tokens[0],
|
|
699
|
+
delay: 0,
|
|
700
|
+
throttle: 0,
|
|
701
|
+
once: false,
|
|
702
|
+
changed: false,
|
|
703
|
+
from: null,
|
|
704
|
+
interval: 0 // for "every Ns"
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
// Handle "every Ns"
|
|
708
|
+
if (spec.event === "every" && tokens.length >= 2) {
|
|
709
|
+
var match = tokens[1].match(/^(\d+)(s|ms)$/);
|
|
710
|
+
if (match) {
|
|
711
|
+
spec.interval = match[2] === "s" ? parseInt(match[1]) * 1000 : parseInt(match[1]);
|
|
712
|
+
spec.event = "every";
|
|
713
|
+
// Continue parsing modifiers after the interval token
|
|
714
|
+
tokens = tokens.slice(2);
|
|
715
|
+
}
|
|
716
|
+
} else {
|
|
717
|
+
tokens = tokens.slice(1);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Parse modifiers
|
|
721
|
+
for (var t = 0; t < tokens.length; t++) {
|
|
722
|
+
var tok = tokens[t];
|
|
723
|
+
if (tok === "once") {
|
|
724
|
+
spec.once = true;
|
|
725
|
+
} else if (tok === "changed") {
|
|
726
|
+
spec.changed = true;
|
|
727
|
+
} else if (tok.indexOf("delay:") === 0) {
|
|
728
|
+
spec.delay = parseTimeValue(tok.slice(6));
|
|
729
|
+
} else if (tok.indexOf("throttle:") === 0) {
|
|
730
|
+
spec.throttle = parseTimeValue(tok.slice(9));
|
|
731
|
+
} else if (tok.indexOf("from:") === 0) {
|
|
732
|
+
spec.from = tok.slice(5);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
specs.push(spec);
|
|
737
|
+
}
|
|
738
|
+
return specs;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Parse a time value like "300ms" or "2s" into milliseconds.
|
|
743
|
+
* @param {string} val
|
|
744
|
+
* @returns {number}
|
|
745
|
+
*/
|
|
746
|
+
function parseTimeValue(val) {
|
|
747
|
+
var m = val.match(/^(\d+)(ms|s)$/);
|
|
748
|
+
if (!m) return 0;
|
|
749
|
+
return m[2] === "s" ? parseInt(m[1]) * 1000 : parseInt(m[1]);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
// Request handling
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Build the request body for a POST/PUT/PATCH request.
|
|
758
|
+
*
|
|
759
|
+
* @param {Element} el – The element that triggered the request.
|
|
760
|
+
* @param {DataContext} ctx – Current data context.
|
|
761
|
+
* @returns {string|null}
|
|
762
|
+
*/
|
|
763
|
+
function buildRequestBody(el, ctx) {
|
|
764
|
+
var body = {};
|
|
765
|
+
|
|
766
|
+
// If the trigger element is (or is inside) a form, serialize the form
|
|
767
|
+
var form = el.tagName.toLowerCase() === "form" ? el : el.closest("form");
|
|
768
|
+
if (form) {
|
|
769
|
+
var formData = new FormData(form);
|
|
770
|
+
formData.forEach(function (value, key) {
|
|
771
|
+
body[key] = value;
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Merge xh-vals
|
|
776
|
+
var valsAttr = el.getAttribute("xh-vals");
|
|
777
|
+
if (valsAttr) {
|
|
778
|
+
try {
|
|
779
|
+
var valsInterpolated = interpolate(valsAttr, ctx, false);
|
|
780
|
+
var vals = JSON.parse(valsInterpolated);
|
|
781
|
+
for (var k in vals) {
|
|
782
|
+
if (vals.hasOwnProperty(k)) {
|
|
783
|
+
body[k] = vals[k];
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
} catch (e) {
|
|
787
|
+
console.error("[xhtmlx] invalid JSON in xh-vals:", valsAttr, e);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Collect xh-model values from the element's scope
|
|
792
|
+
var scope = form || el.closest("[xh-get],[xh-post],[xh-put],[xh-patch],[xh-delete]") || el;
|
|
793
|
+
var modelInputs = scope.querySelectorAll("[xh-model]");
|
|
794
|
+
for (var m = 0; m < modelInputs.length; m++) {
|
|
795
|
+
var mEl = modelInputs[m];
|
|
796
|
+
var field = mEl.getAttribute("xh-model");
|
|
797
|
+
var mTag = mEl.tagName.toLowerCase();
|
|
798
|
+
var mType = (mEl.getAttribute("type") || "").toLowerCase();
|
|
799
|
+
|
|
800
|
+
if (mType === "checkbox") {
|
|
801
|
+
body[field] = mEl.checked;
|
|
802
|
+
} else if (mType === "radio") {
|
|
803
|
+
if (mEl.checked) body[field] = mEl.value;
|
|
804
|
+
} else if (mTag === "select") {
|
|
805
|
+
body[field] = mEl.value;
|
|
806
|
+
} else {
|
|
807
|
+
body[field] = mEl.value;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return JSON.stringify(body);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Parse the xh-headers attribute.
|
|
816
|
+
* @param {Element} el
|
|
817
|
+
* @param {DataContext} ctx
|
|
818
|
+
* @returns {Object}
|
|
819
|
+
*/
|
|
820
|
+
function parseHeaders(el, ctx) {
|
|
821
|
+
var hdrs = {};
|
|
822
|
+
var raw = el.getAttribute("xh-headers");
|
|
823
|
+
if (raw) {
|
|
824
|
+
try {
|
|
825
|
+
var interpolated = interpolate(raw, ctx, false);
|
|
826
|
+
hdrs = JSON.parse(interpolated);
|
|
827
|
+
} catch (e) {
|
|
828
|
+
console.error("[xhtmlx] invalid JSON in xh-headers:", raw, e);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return hdrs;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
// Indicator helpers
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
|
|
838
|
+
function showIndicator(el) {
|
|
839
|
+
var sel = el.getAttribute("xh-indicator");
|
|
840
|
+
if (!sel) return;
|
|
841
|
+
var ind = document.querySelector(sel);
|
|
842
|
+
if (ind) ind.classList.add(config.requestClass);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function hideIndicator(el) {
|
|
846
|
+
var sel = el.getAttribute("xh-indicator");
|
|
847
|
+
if (!sel) return;
|
|
848
|
+
var ind = document.querySelector(sel);
|
|
849
|
+
if (ind) ind.classList.remove(config.requestClass);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
// Custom DOM events
|
|
854
|
+
// ---------------------------------------------------------------------------
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Dispatch a custom event on an element.
|
|
858
|
+
* @param {Element} el
|
|
859
|
+
* @param {string} name – Event name (e.g. "xh:beforeRequest")
|
|
860
|
+
* @param {Object} detail – Event detail data
|
|
861
|
+
* @param {boolean} cancelable
|
|
862
|
+
* @returns {boolean} false if event was preventDefault()'d
|
|
863
|
+
*/
|
|
864
|
+
function emitEvent(el, name, detail, cancelable) {
|
|
865
|
+
var evt = new CustomEvent(name, {
|
|
866
|
+
bubbles: true,
|
|
867
|
+
cancelable: !!cancelable,
|
|
868
|
+
detail: detail || {}
|
|
869
|
+
});
|
|
870
|
+
return el.dispatchEvent(evt);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
// Error template resolution
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Determine which error template URL to use for a given HTTP status code.
|
|
879
|
+
*
|
|
880
|
+
* Resolution order:
|
|
881
|
+
* 1. xh-error-template-{exact code} on the element
|
|
882
|
+
* 2. xh-error-template-{class} on the element (e.g. 4xx)
|
|
883
|
+
* 3. xh-error-template on the element (generic)
|
|
884
|
+
* 4. nearest ancestor xh-error-boundary with template/target
|
|
885
|
+
* 5. config.defaultErrorTemplate global fallback
|
|
886
|
+
* 6. null just add CSS class
|
|
887
|
+
*
|
|
888
|
+
* @param {Element} el
|
|
889
|
+
* @param {number} status
|
|
890
|
+
* @returns {string|null}
|
|
891
|
+
*/
|
|
892
|
+
function resolveErrorTemplate(el, status) {
|
|
893
|
+
// 1. exact code on element
|
|
894
|
+
var exact = el.getAttribute("xh-error-template-" + status);
|
|
895
|
+
if (exact) return exact;
|
|
896
|
+
|
|
897
|
+
// 2. class (4xx, 5xx) on element
|
|
898
|
+
var cls = Math.floor(status / 100) + "xx";
|
|
899
|
+
var classAttr = el.getAttribute("xh-error-template-" + cls);
|
|
900
|
+
if (classAttr) return classAttr;
|
|
901
|
+
|
|
902
|
+
// 3. generic on element
|
|
903
|
+
var generic = el.getAttribute("xh-error-template");
|
|
904
|
+
if (generic) return generic;
|
|
905
|
+
|
|
906
|
+
// 4. nearest ancestor xh-error-boundary
|
|
907
|
+
var boundary = findErrorBoundary(el, status);
|
|
908
|
+
if (boundary) return boundary.template;
|
|
909
|
+
|
|
910
|
+
// 5. global config
|
|
911
|
+
if (config.defaultErrorTemplate) return config.defaultErrorTemplate;
|
|
912
|
+
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Walk up the DOM to find the nearest xh-error-boundary ancestor.
|
|
918
|
+
* An error boundary can have its own xh-error-template[-code] attributes.
|
|
919
|
+
*
|
|
920
|
+
* @param {Element} el
|
|
921
|
+
* @param {number} status
|
|
922
|
+
* @returns {{template: string, target: Element|null}|null}
|
|
923
|
+
*/
|
|
924
|
+
function findErrorBoundary(el, status) {
|
|
925
|
+
var node = el.parentElement;
|
|
926
|
+
while (node) {
|
|
927
|
+
if (node.hasAttribute("xh-error-boundary")) {
|
|
928
|
+
// Check status-specific template on boundary
|
|
929
|
+
var cls = Math.floor(status / 100) + "xx";
|
|
930
|
+
var tmpl = node.getAttribute("xh-error-template-" + status) ||
|
|
931
|
+
node.getAttribute("xh-error-template-" + cls) ||
|
|
932
|
+
node.getAttribute("xh-error-template");
|
|
933
|
+
if (tmpl) {
|
|
934
|
+
return { template: tmpl, boundaryEl: node };
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
node = node.parentElement;
|
|
938
|
+
}
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ---------------------------------------------------------------------------
|
|
943
|
+
// Swap helpers
|
|
944
|
+
// ---------------------------------------------------------------------------
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Get the target element for swapping content.
|
|
948
|
+
* @param {Element} el – The element that triggered the request.
|
|
949
|
+
* @param {boolean} isError – Whether this is an error swap.
|
|
950
|
+
* @returns {Element}
|
|
951
|
+
*/
|
|
952
|
+
function getSwapTarget(el, isError, status) {
|
|
953
|
+
var sel;
|
|
954
|
+
if (isError) {
|
|
955
|
+
// 1. Element-level xh-error-target
|
|
956
|
+
sel = el.getAttribute("xh-error-target");
|
|
957
|
+
if (sel) {
|
|
958
|
+
var t = document.querySelector(sel);
|
|
959
|
+
if (t) return t;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// 2. Error boundary target
|
|
963
|
+
if (status) {
|
|
964
|
+
var boundary = findErrorBoundary(el, status);
|
|
965
|
+
if (boundary) {
|
|
966
|
+
var bTarget = boundary.boundaryEl.getAttribute("xh-error-target");
|
|
967
|
+
if (bTarget) {
|
|
968
|
+
var bt = document.querySelector(bTarget);
|
|
969
|
+
if (bt) return bt;
|
|
970
|
+
}
|
|
971
|
+
// If boundary has no xh-error-target, swap into the boundary itself
|
|
972
|
+
return boundary.boundaryEl;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// 3. Global config error target
|
|
977
|
+
if (config.defaultErrorTarget) {
|
|
978
|
+
var gt = document.querySelector(config.defaultErrorTarget);
|
|
979
|
+
if (gt) return gt;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// 4. Fall back to xh-target or self
|
|
983
|
+
sel = el.getAttribute("xh-target");
|
|
984
|
+
if (sel) {
|
|
985
|
+
var xt = document.querySelector(sel);
|
|
986
|
+
if (xt) return xt;
|
|
987
|
+
}
|
|
988
|
+
} else {
|
|
989
|
+
sel = el.getAttribute("xh-target");
|
|
990
|
+
if (sel) {
|
|
991
|
+
var target = document.querySelector(sel);
|
|
992
|
+
if (target) return target;
|
|
993
|
+
if (config.debug) console.warn("[xhtmlx] target not found:", sel);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return el;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Clean up any intervals or observers associated with elements that are
|
|
1001
|
+
* about to be removed from the DOM.
|
|
1002
|
+
* @param {Element} container
|
|
1003
|
+
*/
|
|
1004
|
+
function cleanupBeforeSwap(container, includeContainer) {
|
|
1005
|
+
// Clean up elements inside the container
|
|
1006
|
+
var all = container.querySelectorAll("*");
|
|
1007
|
+
for (var i = 0; i < all.length; i++) {
|
|
1008
|
+
cleanupElement(all[i]);
|
|
1009
|
+
}
|
|
1010
|
+
// Only clean up the container itself when it will be removed from the DOM
|
|
1011
|
+
// (outerHTML, delete). For innerHTML, the container survives and should
|
|
1012
|
+
// keep its own intervals/observers.
|
|
1013
|
+
if (includeContainer) {
|
|
1014
|
+
cleanupElement(container);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Clean up a single element's intervals and state.
|
|
1020
|
+
* @param {Element} el
|
|
1021
|
+
*/
|
|
1022
|
+
function cleanupElement(el) {
|
|
1023
|
+
var state = elementStates.get(el);
|
|
1024
|
+
if (!state) return;
|
|
1025
|
+
if (state.intervalIds) {
|
|
1026
|
+
for (var j = 0; j < state.intervalIds.length; j++) {
|
|
1027
|
+
clearInterval(state.intervalIds[j]);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (state.observers) {
|
|
1031
|
+
for (var k = 0; k < state.observers.length; k++) {
|
|
1032
|
+
state.observers[k].disconnect();
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (state.ws) {
|
|
1036
|
+
state.ws.close(1000);
|
|
1037
|
+
state.ws = null;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Perform the DOM swap.
|
|
1043
|
+
*
|
|
1044
|
+
* @param {Element} target – The target element.
|
|
1045
|
+
* @param {DocumentFragment} fragment – Rendered content.
|
|
1046
|
+
* @param {string} mode – Swap mode.
|
|
1047
|
+
* @returns {Element|null} The element that should be processed recursively,
|
|
1048
|
+
* or null for "none"/"delete".
|
|
1049
|
+
*/
|
|
1050
|
+
function performSwap(target, fragment, mode) {
|
|
1051
|
+
switch (mode) {
|
|
1052
|
+
case "innerHTML":
|
|
1053
|
+
cleanupBeforeSwap(target, false);
|
|
1054
|
+
target.innerHTML = "";
|
|
1055
|
+
target.appendChild(fragment);
|
|
1056
|
+
return target;
|
|
1057
|
+
|
|
1058
|
+
case "outerHTML":
|
|
1059
|
+
cleanupBeforeSwap(target, true);
|
|
1060
|
+
var placeholder = document.createComment("xhtmlx-swap");
|
|
1061
|
+
target.parentNode.insertBefore(placeholder, target);
|
|
1062
|
+
target.parentNode.removeChild(target);
|
|
1063
|
+
// Insert all children of fragment before the placeholder
|
|
1064
|
+
var wrapper = document.createElement("div");
|
|
1065
|
+
wrapper.appendChild(fragment);
|
|
1066
|
+
var children = Array.prototype.slice.call(wrapper.childNodes);
|
|
1067
|
+
for (var i = 0; i < children.length; i++) {
|
|
1068
|
+
placeholder.parentNode.insertBefore(children[i], placeholder);
|
|
1069
|
+
}
|
|
1070
|
+
placeholder.parentNode.removeChild(placeholder);
|
|
1071
|
+
// Return the parent so we process the new nodes
|
|
1072
|
+
return children.length === 1 && children[0].nodeType === 1 ? children[0] : null;
|
|
1073
|
+
|
|
1074
|
+
case "beforeend":
|
|
1075
|
+
target.appendChild(fragment);
|
|
1076
|
+
return target;
|
|
1077
|
+
|
|
1078
|
+
case "afterbegin":
|
|
1079
|
+
target.insertBefore(fragment, target.firstChild);
|
|
1080
|
+
return target;
|
|
1081
|
+
|
|
1082
|
+
case "beforebegin":
|
|
1083
|
+
target.parentNode.insertBefore(fragment, target);
|
|
1084
|
+
return target.parentNode;
|
|
1085
|
+
|
|
1086
|
+
case "afterend":
|
|
1087
|
+
target.parentNode.insertBefore(fragment, target.nextSibling);
|
|
1088
|
+
return target.parentNode;
|
|
1089
|
+
|
|
1090
|
+
case "delete":
|
|
1091
|
+
cleanupBeforeSwap(target, true);
|
|
1092
|
+
target.remove();
|
|
1093
|
+
return null;
|
|
1094
|
+
|
|
1095
|
+
case "none":
|
|
1096
|
+
return null;
|
|
1097
|
+
|
|
1098
|
+
default:
|
|
1099
|
+
console.warn("[xhtmlx] unknown swap mode:", mode);
|
|
1100
|
+
cleanupBeforeSwap(target, false);
|
|
1101
|
+
target.innerHTML = "";
|
|
1102
|
+
target.appendChild(fragment);
|
|
1103
|
+
return target;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// ---------------------------------------------------------------------------
|
|
1108
|
+
// Rendering pipeline
|
|
1109
|
+
// ---------------------------------------------------------------------------
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Render a template HTML string with a DataContext, producing a
|
|
1113
|
+
* DocumentFragment ready for DOM insertion.
|
|
1114
|
+
*
|
|
1115
|
+
* Steps:
|
|
1116
|
+
* 1. Interpolate {{field}} tokens
|
|
1117
|
+
* 2. Parse into a DocumentFragment
|
|
1118
|
+
* 3. Process directives (xh-each, xh-if, xh-unless, xh-text, xh-html, xh-attr-*)
|
|
1119
|
+
*
|
|
1120
|
+
* @param {string} html
|
|
1121
|
+
* @param {DataContext} ctx
|
|
1122
|
+
* @returns {DocumentFragment}
|
|
1123
|
+
*/
|
|
1124
|
+
function renderTemplate(html, ctx) {
|
|
1125
|
+
// 1. Parse into fragment (no global interpolation — that would replace
|
|
1126
|
+
// {{field}} inside xh-* attributes with the wrong context)
|
|
1127
|
+
var tpl = document.createElement("template");
|
|
1128
|
+
tpl.innerHTML = html;
|
|
1129
|
+
var fragment = document.importNode(tpl.content, true);
|
|
1130
|
+
|
|
1131
|
+
// 2. Process directives in the fragment
|
|
1132
|
+
// We need a temporary container because DocumentFragment doesn't support
|
|
1133
|
+
// querySelectorAll with :scope or certain traversals in all browsers.
|
|
1134
|
+
var container = document.createElement("div");
|
|
1135
|
+
container.appendChild(fragment);
|
|
1136
|
+
|
|
1137
|
+
// 2a. Interpolate {{field}} in text nodes and non-xh-* attributes only.
|
|
1138
|
+
// This leaves xh-get URLs, xh-text values, etc. for later processing
|
|
1139
|
+
// with the correct per-item data context.
|
|
1140
|
+
interpolateDOM(container, ctx);
|
|
1141
|
+
|
|
1142
|
+
// Process xh-each first (top-level only, they handle their own children)
|
|
1143
|
+
var eachEls = Array.prototype.slice.call(container.querySelectorAll("[xh-each]"));
|
|
1144
|
+
for (var i = 0; i < eachEls.length; i++) {
|
|
1145
|
+
if (!eachEls[i].parentNode) continue;
|
|
1146
|
+
// Only process top-level xh-each (not nested inside another xh-each)
|
|
1147
|
+
var isNested = false;
|
|
1148
|
+
var check = eachEls[i].parentNode;
|
|
1149
|
+
while (check && check !== container) {
|
|
1150
|
+
if (check.hasAttribute && check.hasAttribute("xh-each")) {
|
|
1151
|
+
isNested = true;
|
|
1152
|
+
break;
|
|
1153
|
+
}
|
|
1154
|
+
check = check.parentNode;
|
|
1155
|
+
}
|
|
1156
|
+
if (!isNested) {
|
|
1157
|
+
processEach(eachEls[i], ctx);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Process other bindings (skip elements already handled by xh-each)
|
|
1162
|
+
var allEls = Array.prototype.slice.call(container.querySelectorAll("*"));
|
|
1163
|
+
for (var j = 0; j < allEls.length; j++) {
|
|
1164
|
+
if (!allEls[j].parentNode) continue;
|
|
1165
|
+
// Skip elements that still have xh-each (shouldn't happen, but guard)
|
|
1166
|
+
if (allEls[j].hasAttribute("xh-each")) continue;
|
|
1167
|
+
// Skip elements with REST verbs — they will be processed by processNode
|
|
1168
|
+
if (getRestVerb(allEls[j])) continue;
|
|
1169
|
+
// Skip elements created by xh-each — they were already bound with the
|
|
1170
|
+
// correct per-item context inside processEach
|
|
1171
|
+
if (allEls[j].hasAttribute("data-xh-each-item") ||
|
|
1172
|
+
allEls[j].closest("[data-xh-each-item]")) continue;
|
|
1173
|
+
applyBindings(allEls[j], ctx);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Move children back into a new fragment
|
|
1177
|
+
var resultFragment = document.createDocumentFragment();
|
|
1178
|
+
while (container.firstChild) {
|
|
1179
|
+
resultFragment.appendChild(container.firstChild);
|
|
1180
|
+
}
|
|
1181
|
+
return resultFragment;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ---------------------------------------------------------------------------
|
|
1185
|
+
// Settle class helpers
|
|
1186
|
+
// ---------------------------------------------------------------------------
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Apply settle classes to newly inserted elements.
|
|
1190
|
+
* Adds "xh-added" immediately, then swaps to "xh-settled" after two
|
|
1191
|
+
* animation frames so that CSS transitions can react.
|
|
1192
|
+
*
|
|
1193
|
+
* @param {Element} processTarget – The container of new elements.
|
|
1194
|
+
*/
|
|
1195
|
+
function applySettleClasses(processTarget) {
|
|
1196
|
+
if (!processTarget) return;
|
|
1197
|
+
var newEls = processTarget.querySelectorAll ? Array.prototype.slice.call(processTarget.querySelectorAll("*")) : [];
|
|
1198
|
+
if (processTarget.classList) newEls.unshift(processTarget);
|
|
1199
|
+
|
|
1200
|
+
for (var se = 0; se < newEls.length; se++) {
|
|
1201
|
+
if (newEls[se].classList) newEls[se].classList.add("xh-added");
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
requestAnimationFrame(function () {
|
|
1205
|
+
requestAnimationFrame(function () {
|
|
1206
|
+
for (var sf = 0; sf < newEls.length; sf++) {
|
|
1207
|
+
if (newEls[sf].classList) {
|
|
1208
|
+
newEls[sf].classList.remove("xh-added");
|
|
1209
|
+
newEls[sf].classList.add("xh-settled");
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// ---------------------------------------------------------------------------
|
|
1217
|
+
// Retry with backoff helper
|
|
1218
|
+
// ---------------------------------------------------------------------------
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Fetch with automatic retry on 5xx errors and network failures.
|
|
1222
|
+
*
|
|
1223
|
+
* @param {string} url – Request URL.
|
|
1224
|
+
* @param {Object} opts – Fetch options.
|
|
1225
|
+
* @param {number} retries – Maximum number of retries.
|
|
1226
|
+
* @param {number} delay – Base delay in ms (doubled each attempt).
|
|
1227
|
+
* @param {number} attempt – Current attempt (0-based).
|
|
1228
|
+
* @param {Element} el – Element for emitting events.
|
|
1229
|
+
* @returns {Promise<Response>}
|
|
1230
|
+
*/
|
|
1231
|
+
function fetchWithRetry(url, opts, retries, delay, attempt, el) {
|
|
1232
|
+
return fetch(url, opts).then(function (response) {
|
|
1233
|
+
if (!response.ok && response.status >= 500 && attempt < retries) {
|
|
1234
|
+
emitEvent(el, "xh:retry", { attempt: attempt + 1, maxRetries: retries, status: response.status }, false);
|
|
1235
|
+
return new Promise(function (resolve) {
|
|
1236
|
+
setTimeout(function () {
|
|
1237
|
+
resolve(fetchWithRetry(url, opts, retries, delay, attempt + 1, el));
|
|
1238
|
+
}, delay * Math.pow(2, attempt));
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
return response;
|
|
1242
|
+
}).catch(function (err) {
|
|
1243
|
+
if (attempt < retries) {
|
|
1244
|
+
emitEvent(el, "xh:retry", { attempt: attempt + 1, maxRetries: retries, error: err.message }, false);
|
|
1245
|
+
return new Promise(function (resolve) {
|
|
1246
|
+
setTimeout(function () {
|
|
1247
|
+
resolve(fetchWithRetry(url, opts, retries, delay, attempt + 1, el));
|
|
1248
|
+
}, delay * Math.pow(2, attempt));
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
throw err;
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// ---------------------------------------------------------------------------
|
|
1256
|
+
// Validation
|
|
1257
|
+
// ---------------------------------------------------------------------------
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Validate fields within the scope of an element.
|
|
1261
|
+
* Looks for [xh-validate] elements in the form or element scope and checks
|
|
1262
|
+
* rules: required, pattern, min/max, minlength/maxlength.
|
|
1263
|
+
*
|
|
1264
|
+
* @param {Element} el – The element that triggered the request.
|
|
1265
|
+
* @returns {boolean} true if all valid, false if errors found.
|
|
1266
|
+
*/
|
|
1267
|
+
function validateElement(el) {
|
|
1268
|
+
var scope = el.tagName.toLowerCase() === "form" ? el : el.closest("form") || el;
|
|
1269
|
+
var fields = scope.querySelectorAll("[xh-validate]");
|
|
1270
|
+
var errors = [];
|
|
1271
|
+
|
|
1272
|
+
for (var i = 0; i < fields.length; i++) {
|
|
1273
|
+
var field = fields[i];
|
|
1274
|
+
var rules = field.getAttribute("xh-validate").split(" ");
|
|
1275
|
+
var value = field.value || "";
|
|
1276
|
+
var fieldName = field.getAttribute("name") || field.getAttribute("xh-model") || "field";
|
|
1277
|
+
var customMsg = field.getAttribute("xh-validate-message");
|
|
1278
|
+
var errorClass = field.getAttribute("xh-validate-class") || "xh-invalid";
|
|
1279
|
+
var errorTarget = field.getAttribute("xh-validate-target");
|
|
1280
|
+
var error = null;
|
|
1281
|
+
|
|
1282
|
+
for (var r = 0; r < rules.length; r++) {
|
|
1283
|
+
var rule = rules[r];
|
|
1284
|
+
if (rule === "required" && !value.trim()) {
|
|
1285
|
+
error = customMsg || fieldName + " is required";
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// xh-validate-pattern
|
|
1290
|
+
var pattern = field.getAttribute("xh-validate-pattern");
|
|
1291
|
+
if (pattern && value && !new RegExp(pattern).test(value)) {
|
|
1292
|
+
error = customMsg || fieldName + " format is invalid";
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// xh-validate-min / xh-validate-max
|
|
1296
|
+
var min = field.getAttribute("xh-validate-min");
|
|
1297
|
+
var max = field.getAttribute("xh-validate-max");
|
|
1298
|
+
if (min != null && Number(value) < Number(min)) {
|
|
1299
|
+
error = customMsg || fieldName + " must be at least " + min;
|
|
1300
|
+
}
|
|
1301
|
+
if (max != null && Number(value) > Number(max)) {
|
|
1302
|
+
error = customMsg || fieldName + " must be at most " + max;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// xh-validate-minlength / xh-validate-maxlength
|
|
1306
|
+
var minlen = field.getAttribute("xh-validate-minlength");
|
|
1307
|
+
var maxlen = field.getAttribute("xh-validate-maxlength");
|
|
1308
|
+
if (minlen != null && value.length < Number(minlen)) {
|
|
1309
|
+
error = customMsg || fieldName + " must be at least " + minlen + " characters";
|
|
1310
|
+
}
|
|
1311
|
+
if (maxlen != null && value.length > Number(maxlen)) {
|
|
1312
|
+
error = customMsg || fieldName + " must be at most " + maxlen + " characters";
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (error) {
|
|
1316
|
+
field.classList.add(errorClass);
|
|
1317
|
+
if (errorTarget) {
|
|
1318
|
+
var tgt = document.querySelector(errorTarget);
|
|
1319
|
+
if (tgt) tgt.textContent = error;
|
|
1320
|
+
}
|
|
1321
|
+
errors.push({ field: fieldName, message: error, element: field });
|
|
1322
|
+
} else {
|
|
1323
|
+
field.classList.remove(errorClass);
|
|
1324
|
+
if (errorTarget) {
|
|
1325
|
+
var tgt2 = document.querySelector(errorTarget);
|
|
1326
|
+
if (tgt2) tgt2.textContent = "";
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (errors.length > 0) {
|
|
1332
|
+
emitEvent(el, "xh:validationError", { errors: errors }, false);
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ---------------------------------------------------------------------------
|
|
1339
|
+
// Main request handler
|
|
1340
|
+
// ---------------------------------------------------------------------------
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Execute a REST request triggered by an element.
|
|
1344
|
+
*
|
|
1345
|
+
* @param {Element} el
|
|
1346
|
+
* @param {DataContext} ctx
|
|
1347
|
+
* @param {string[]} templateStack – For circular template detection.
|
|
1348
|
+
*/
|
|
1349
|
+
function executeRequest(el, ctx, templateStack) {
|
|
1350
|
+
var restInfo = getRestVerb(el);
|
|
1351
|
+
if (!restInfo) return;
|
|
1352
|
+
|
|
1353
|
+
// Increment generation counter to handle stale responses
|
|
1354
|
+
var gen = (generationMap.get(el) || 0) + 1;
|
|
1355
|
+
generationMap.set(el, gen);
|
|
1356
|
+
|
|
1357
|
+
// -- Request deduplication: skip if already in-flight ---------------------
|
|
1358
|
+
var state = elementStates.get(el);
|
|
1359
|
+
if (state && state.requestInFlight) {
|
|
1360
|
+
if (config.debug) console.warn("[xhtmlx] request already in-flight, skipping");
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Mark request in-flight
|
|
1365
|
+
if (state) state.requestInFlight = true;
|
|
1366
|
+
|
|
1367
|
+
// Interpolate URL with URI encoding, prepend API prefix for versioning
|
|
1368
|
+
var url = interpolate(restInfo.url, ctx, true);
|
|
1369
|
+
if (config.apiPrefix && url.indexOf("://") === -1) {
|
|
1370
|
+
url = config.apiPrefix + url;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// Build fetch options
|
|
1374
|
+
var fetchOpts = { method: restInfo.verb, headers: {} };
|
|
1375
|
+
|
|
1376
|
+
// Custom headers
|
|
1377
|
+
var customHeaders = parseHeaders(el, ctx);
|
|
1378
|
+
for (var h in customHeaders) {
|
|
1379
|
+
if (customHeaders.hasOwnProperty(h)) {
|
|
1380
|
+
fetchOpts.headers[h] = customHeaders[h];
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Request body for POST/PUT/PATCH
|
|
1385
|
+
if (restInfo.verb === "POST" || restInfo.verb === "PUT" || restInfo.verb === "PATCH") {
|
|
1386
|
+
fetchOpts.headers["Content-Type"] = fetchOpts.headers["Content-Type"] || "application/json";
|
|
1387
|
+
fetchOpts.body = buildRequestBody(el, ctx);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Run global plugin hooks before request
|
|
1391
|
+
var hookAllowed = runHooks("beforeRequest", {
|
|
1392
|
+
url: url, method: restInfo.verb, headers: fetchOpts.headers, element: el
|
|
1393
|
+
});
|
|
1394
|
+
if (!hookAllowed) {
|
|
1395
|
+
if (state) state.requestInFlight = false;
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Emit xh:beforeRequest (cancelable)
|
|
1400
|
+
var allowed = emitEvent(el, "xh:beforeRequest", {
|
|
1401
|
+
url: url,
|
|
1402
|
+
method: restInfo.verb,
|
|
1403
|
+
headers: fetchOpts.headers
|
|
1404
|
+
}, true);
|
|
1405
|
+
if (!allowed) {
|
|
1406
|
+
if (state) state.requestInFlight = false;
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Validate before sending
|
|
1411
|
+
if (!validateElement(el)) {
|
|
1412
|
+
if (state) state.requestInFlight = false;
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// Show indicator
|
|
1417
|
+
showIndicator(el);
|
|
1418
|
+
|
|
1419
|
+
// Accessibility: mark target as busy
|
|
1420
|
+
var ariaTarget = getSwapTarget(el, false);
|
|
1421
|
+
if (ariaTarget) ariaTarget.setAttribute("aria-busy", "true");
|
|
1422
|
+
|
|
1423
|
+
// xh-disabled-class: add CSS class while request is in-flight
|
|
1424
|
+
var disabledClass = el.getAttribute("xh-disabled-class");
|
|
1425
|
+
if (disabledClass) {
|
|
1426
|
+
el.classList.add(disabledClass);
|
|
1427
|
+
el.setAttribute("aria-disabled", "true");
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// -- Response caching (xh-cache) ------------------------------------------
|
|
1431
|
+
var cacheAttr = el.getAttribute("xh-cache");
|
|
1432
|
+
var cacheKey = restInfo.verb + ":" + url;
|
|
1433
|
+
|
|
1434
|
+
if (cacheAttr && restInfo.verb === "GET") {
|
|
1435
|
+
var cached = responseCache.get(cacheKey);
|
|
1436
|
+
if (cached) {
|
|
1437
|
+
var age = Date.now() - cached.timestamp;
|
|
1438
|
+
var ttl = cacheAttr === "forever" ? Infinity : parseInt(cacheAttr, 10) * 1000;
|
|
1439
|
+
if (age < ttl) {
|
|
1440
|
+
// Use cached response — create a fake Response-like object
|
|
1441
|
+
var fakeResponse = {
|
|
1442
|
+
ok: true, status: 200, statusText: "OK (cached)",
|
|
1443
|
+
text: function () { return Promise.resolve(cached.data); }
|
|
1444
|
+
};
|
|
1445
|
+
processFetchResponse(fakeResponse);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
responseCache.delete(cacheKey);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// -- Retry with backoff (xh-retry) ----------------------------------------
|
|
1453
|
+
var retryAttr = el.getAttribute("xh-retry");
|
|
1454
|
+
var retryCount = retryAttr ? parseInt(retryAttr, 10) : 0;
|
|
1455
|
+
var retryDelayAttr = el.getAttribute("xh-retry-delay");
|
|
1456
|
+
var retryDelay = retryDelayAttr ? parseInt(retryDelayAttr, 10) : 1000;
|
|
1457
|
+
|
|
1458
|
+
var fetchPromise = retryCount > 0
|
|
1459
|
+
? fetchWithRetry(url, fetchOpts, retryCount, retryDelay, 0, el)
|
|
1460
|
+
: fetch(url, fetchOpts);
|
|
1461
|
+
|
|
1462
|
+
fetchPromise
|
|
1463
|
+
.then(processFetchResponse)
|
|
1464
|
+
.catch(function (err) {
|
|
1465
|
+
console.error("[xhtmlx] request failed:", url, err);
|
|
1466
|
+
handleError(el, ctx, 0, "Network Error", err.message, templateStack);
|
|
1467
|
+
})
|
|
1468
|
+
.finally(function () {
|
|
1469
|
+
hideIndicator(el);
|
|
1470
|
+
if (ariaTarget) ariaTarget.removeAttribute("aria-busy");
|
|
1471
|
+
if (disabledClass) {
|
|
1472
|
+
el.classList.remove(disabledClass);
|
|
1473
|
+
el.removeAttribute("aria-disabled");
|
|
1474
|
+
}
|
|
1475
|
+
if (state) state.requestInFlight = false;
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
function processFetchResponse(response) {
|
|
1479
|
+
// Emit xh:afterRequest
|
|
1480
|
+
emitEvent(el, "xh:afterRequest", { url: url, status: response.status }, false);
|
|
1481
|
+
|
|
1482
|
+
// Stale response check
|
|
1483
|
+
if (generationMap.get(el) !== gen) {
|
|
1484
|
+
if (config.debug) console.warn("[xhtmlx] discarding stale response for", url);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
if (!response.ok) {
|
|
1489
|
+
// Error path
|
|
1490
|
+
return response.text().then(function (bodyText) {
|
|
1491
|
+
var errorBody;
|
|
1492
|
+
try {
|
|
1493
|
+
errorBody = JSON.parse(bodyText);
|
|
1494
|
+
} catch (_) {
|
|
1495
|
+
errorBody = bodyText;
|
|
1496
|
+
}
|
|
1497
|
+
handleError(el, ctx, response.status, response.statusText, errorBody, templateStack);
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// Success path — parse JSON
|
|
1502
|
+
return response.text().then(function (bodyText) {
|
|
1503
|
+
var jsonData;
|
|
1504
|
+
if (bodyText.trim() === "") {
|
|
1505
|
+
jsonData = {};
|
|
1506
|
+
} else {
|
|
1507
|
+
try {
|
|
1508
|
+
jsonData = JSON.parse(bodyText);
|
|
1509
|
+
} catch (e) {
|
|
1510
|
+
console.error("[xhtmlx] invalid JSON response from", url, e);
|
|
1511
|
+
handleError(el, ctx, response.status, "Invalid JSON", bodyText, templateStack);
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Cache the response if xh-cache is set
|
|
1517
|
+
if (cacheAttr && restInfo.verb === "GET" && bodyText) {
|
|
1518
|
+
responseCache.set(cacheKey, { data: bodyText, timestamp: Date.now() });
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
var childCtx = new MutableDataContext(jsonData, ctx);
|
|
1522
|
+
|
|
1523
|
+
// Resolve and render template
|
|
1524
|
+
return resolveTemplate(el, templateStack).then(function (tmpl) {
|
|
1525
|
+
var swapMode = el.getAttribute("xh-swap") || config.defaultSwapMode;
|
|
1526
|
+
var target = getSwapTarget(el, false);
|
|
1527
|
+
|
|
1528
|
+
if (tmpl.html !== null) {
|
|
1529
|
+
// Render from template HTML
|
|
1530
|
+
var fragment = renderTemplate(tmpl.html, childCtx);
|
|
1531
|
+
|
|
1532
|
+
// Emit xh:beforeSwap (cancelable)
|
|
1533
|
+
var swapAllowed = emitEvent(el, "xh:beforeSwap", {
|
|
1534
|
+
target: target,
|
|
1535
|
+
fragment: fragment,
|
|
1536
|
+
swapMode: swapMode
|
|
1537
|
+
}, true);
|
|
1538
|
+
if (!swapAllowed) return;
|
|
1539
|
+
|
|
1540
|
+
var processTarget = performSwap(target, fragment, swapMode);
|
|
1541
|
+
|
|
1542
|
+
// Apply settle classes to newly added elements
|
|
1543
|
+
applySettleClasses(processTarget);
|
|
1544
|
+
|
|
1545
|
+
// Recursively process new content
|
|
1546
|
+
if (processTarget) {
|
|
1547
|
+
processNode(processTarget, childCtx, tmpl.templateStack);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Emit xh:afterSwap
|
|
1551
|
+
emitEvent(el, "xh:afterSwap", { target: target }, false);
|
|
1552
|
+
|
|
1553
|
+
// -- xh-focus: focus management after swap ----------------------------
|
|
1554
|
+
var focusEl = el.getAttribute("xh-focus");
|
|
1555
|
+
if (focusEl && focusEl !== "auto") {
|
|
1556
|
+
var toFocus = document.querySelector(focusEl);
|
|
1557
|
+
if (toFocus) toFocus.focus();
|
|
1558
|
+
} else if (focusEl === "auto" && processTarget) {
|
|
1559
|
+
var focusable = processTarget.querySelector("a, button, input, select, textarea, [tabindex]");
|
|
1560
|
+
if (focusable) focusable.focus();
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
} else {
|
|
1564
|
+
// Self-binding: apply bindings directly to the element
|
|
1565
|
+
applyBindings(el, childCtx);
|
|
1566
|
+
|
|
1567
|
+
// Also process children for bindings
|
|
1568
|
+
processBindingsInTree(el, childCtx);
|
|
1569
|
+
|
|
1570
|
+
// Emit swap events
|
|
1571
|
+
emitEvent(el, "xh:afterSwap", { target: el }, false);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// -- xh-push-url -------------------------------------------------------
|
|
1575
|
+
var pushUrl = el.getAttribute("xh-push-url");
|
|
1576
|
+
if (pushUrl) {
|
|
1577
|
+
var historyUrl = pushUrl === "true" ? url : interpolate(pushUrl, childCtx, false);
|
|
1578
|
+
var historyState = {
|
|
1579
|
+
xhtmlx: true,
|
|
1580
|
+
url: restInfo.url,
|
|
1581
|
+
verb: restInfo.verb,
|
|
1582
|
+
targetSel: el.getAttribute("xh-target"),
|
|
1583
|
+
templateUrl: el.getAttribute("xh-template")
|
|
1584
|
+
};
|
|
1585
|
+
history.pushState(historyState, "", historyUrl);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// -- xh-replace-url ----------------------------------------------------
|
|
1589
|
+
var replaceUrl = el.getAttribute("xh-replace-url");
|
|
1590
|
+
if (replaceUrl) {
|
|
1591
|
+
var rUrl = replaceUrl === "true" ? url : interpolate(replaceUrl, childCtx, false);
|
|
1592
|
+
history.replaceState({ xhtmlx: true }, "", rUrl);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Store data context and URLs for reload/versioning
|
|
1596
|
+
if (state) {
|
|
1597
|
+
state.dataContext = childCtx;
|
|
1598
|
+
state.templateUrl = el.getAttribute("xh-template");
|
|
1599
|
+
state.apiUrl = restInfo.url;
|
|
1600
|
+
state.apiVerb = restInfo.verb;
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* Handle an error response: resolve error template, render, and swap.
|
|
1609
|
+
*
|
|
1610
|
+
* @param {Element} el
|
|
1611
|
+
* @param {DataContext} ctx
|
|
1612
|
+
* @param {number} status
|
|
1613
|
+
* @param {string} statusText
|
|
1614
|
+
* @param {*} body
|
|
1615
|
+
* @param {string[]} templateStack
|
|
1616
|
+
*/
|
|
1617
|
+
function handleError(el, ctx, status, statusText, body, templateStack) {
|
|
1618
|
+
var errorData = { status: status, statusText: statusText, body: body };
|
|
1619
|
+
var errorCtx = new DataContext(errorData, ctx);
|
|
1620
|
+
|
|
1621
|
+
// Emit xh:responseError
|
|
1622
|
+
emitEvent(el, "xh:responseError", errorData, false);
|
|
1623
|
+
|
|
1624
|
+
// Resolve error template
|
|
1625
|
+
var errorTemplateUrl = resolveErrorTemplate(el, status);
|
|
1626
|
+
|
|
1627
|
+
if (!errorTemplateUrl) {
|
|
1628
|
+
// No error template — just add error class
|
|
1629
|
+
el.classList.add(config.errorClass);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// Fetch and render error template
|
|
1634
|
+
fetchTemplate(errorTemplateUrl)
|
|
1635
|
+
.then(function (html) {
|
|
1636
|
+
var fragment = renderTemplate(html, errorCtx);
|
|
1637
|
+
var errorTarget = getSwapTarget(el, true, status);
|
|
1638
|
+
var swapMode = el.getAttribute("xh-swap") || config.defaultSwapMode;
|
|
1639
|
+
|
|
1640
|
+
var swapAllowed = emitEvent(el, "xh:beforeSwap", {
|
|
1641
|
+
target: errorTarget,
|
|
1642
|
+
fragment: fragment,
|
|
1643
|
+
swapMode: swapMode,
|
|
1644
|
+
isError: true
|
|
1645
|
+
}, true);
|
|
1646
|
+
if (!swapAllowed) return;
|
|
1647
|
+
|
|
1648
|
+
var processTarget = performSwap(errorTarget, fragment, swapMode);
|
|
1649
|
+
|
|
1650
|
+
// Apply settle classes to newly added error elements
|
|
1651
|
+
applySettleClasses(processTarget);
|
|
1652
|
+
|
|
1653
|
+
if (processTarget) {
|
|
1654
|
+
processNode(processTarget, errorCtx, templateStack);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// Accessibility: mark error container with role="alert"
|
|
1658
|
+
errorTarget.setAttribute("role", "alert");
|
|
1659
|
+
|
|
1660
|
+
el.classList.add(config.errorClass);
|
|
1661
|
+
emitEvent(el, "xh:afterSwap", { target: errorTarget, isError: true }, false);
|
|
1662
|
+
})
|
|
1663
|
+
.catch(function (err) {
|
|
1664
|
+
console.error("[xhtmlx] error template fetch failed:", err);
|
|
1665
|
+
el.classList.add(config.errorClass);
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// ---------------------------------------------------------------------------
|
|
1670
|
+
// Trigger attachment
|
|
1671
|
+
// ---------------------------------------------------------------------------
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* Attach trigger listeners to an element.
|
|
1675
|
+
*
|
|
1676
|
+
* @param {Element} el
|
|
1677
|
+
* @param {DataContext} ctx
|
|
1678
|
+
* @param {string[]} templateStack
|
|
1679
|
+
*/
|
|
1680
|
+
function attachTriggers(el, ctx, templateStack) {
|
|
1681
|
+
var triggerAttr = el.getAttribute("xh-trigger");
|
|
1682
|
+
var specs;
|
|
1683
|
+
|
|
1684
|
+
if (triggerAttr) {
|
|
1685
|
+
specs = parseTrigger(triggerAttr);
|
|
1686
|
+
} else {
|
|
1687
|
+
// Use default trigger
|
|
1688
|
+
specs = [{ event: defaultTrigger(el), delay: 0, throttle: 0, once: false, changed: false, from: null, interval: 0 }];
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
var state = elementStates.get(el) || {};
|
|
1692
|
+
state.triggerSpecs = specs;
|
|
1693
|
+
state.intervalIds = state.intervalIds || [];
|
|
1694
|
+
state.observers = state.observers || [];
|
|
1695
|
+
state.processed = true;
|
|
1696
|
+
elementStates.set(el, state);
|
|
1697
|
+
|
|
1698
|
+
for (var i = 0; i < specs.length; i++) {
|
|
1699
|
+
attachSingleTrigger(el, ctx, templateStack, specs[i], state);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* Attach a single trigger spec to an element.
|
|
1705
|
+
*/
|
|
1706
|
+
function attachSingleTrigger(el, ctx, templateStack, spec, state) {
|
|
1707
|
+
// --- "load" trigger: fire immediately -------------------------------------
|
|
1708
|
+
if (spec.event === "load") {
|
|
1709
|
+
executeRequest(el, ctx, templateStack);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// --- "every Ns" trigger: set an interval ---------------------------------
|
|
1714
|
+
if (spec.event === "every" && spec.interval > 0) {
|
|
1715
|
+
var intervalId = setInterval(function () {
|
|
1716
|
+
executeRequest(el, ctx, templateStack);
|
|
1717
|
+
}, spec.interval);
|
|
1718
|
+
state.intervalIds.push(intervalId);
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// --- "revealed" trigger: IntersectionObserver -----------------------------
|
|
1723
|
+
if (spec.event === "revealed") {
|
|
1724
|
+
var observer = new IntersectionObserver(function (entries) {
|
|
1725
|
+
for (var e = 0; e < entries.length; e++) {
|
|
1726
|
+
if (entries[e].isIntersecting) {
|
|
1727
|
+
executeRequest(el, ctx, templateStack);
|
|
1728
|
+
if (spec.once) observer.disconnect();
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
}, { threshold: 0.1 });
|
|
1732
|
+
observer.observe(el);
|
|
1733
|
+
state.observers.push(observer);
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// --- Standard DOM event trigger ------------------------------------------
|
|
1738
|
+
var listenTarget = el;
|
|
1739
|
+
if (spec.from) {
|
|
1740
|
+
var fromEl = document.querySelector(spec.from);
|
|
1741
|
+
if (fromEl) {
|
|
1742
|
+
listenTarget = fromEl;
|
|
1743
|
+
} else if (config.debug) {
|
|
1744
|
+
console.warn("[xhtmlx] from: selector not found:", spec.from);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// Build the handler with optional modifiers
|
|
1749
|
+
var handler = buildHandler(el, ctx, templateStack, spec);
|
|
1750
|
+
|
|
1751
|
+
var eventOptions = spec.once ? { once: true } : false;
|
|
1752
|
+
listenTarget.addEventListener(spec.event, handler, eventOptions);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* Build an event handler function incorporating delay, throttle, and changed
|
|
1757
|
+
* modifiers.
|
|
1758
|
+
*/
|
|
1759
|
+
function buildHandler(el, ctx, templateStack, spec) {
|
|
1760
|
+
var lastValue = undefined;
|
|
1761
|
+
var delayTimer = null;
|
|
1762
|
+
var throttleTimer = null;
|
|
1763
|
+
var throttlePending = false;
|
|
1764
|
+
|
|
1765
|
+
return function (evt) {
|
|
1766
|
+
// Prevent default for forms
|
|
1767
|
+
if (spec.event === "submit") {
|
|
1768
|
+
evt.preventDefault();
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// "changed" modifier — only fire if value changed
|
|
1772
|
+
if (spec.changed) {
|
|
1773
|
+
var currentValue;
|
|
1774
|
+
var source = evt.target || el;
|
|
1775
|
+
if ("value" in source) {
|
|
1776
|
+
currentValue = source.value;
|
|
1777
|
+
} else {
|
|
1778
|
+
currentValue = source.textContent;
|
|
1779
|
+
}
|
|
1780
|
+
if (currentValue === lastValue) return;
|
|
1781
|
+
lastValue = currentValue;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
var fire = function () {
|
|
1785
|
+
executeRequest(el, ctx, templateStack);
|
|
1786
|
+
};
|
|
1787
|
+
|
|
1788
|
+
// "delay" modifier — debounce
|
|
1789
|
+
if (spec.delay > 0) {
|
|
1790
|
+
if (delayTimer) clearTimeout(delayTimer);
|
|
1791
|
+
delayTimer = setTimeout(fire, spec.delay);
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// "throttle" modifier
|
|
1796
|
+
if (spec.throttle > 0) {
|
|
1797
|
+
if (throttleTimer) {
|
|
1798
|
+
throttlePending = true;
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
fire();
|
|
1802
|
+
throttleTimer = setTimeout(function () {
|
|
1803
|
+
throttleTimer = null;
|
|
1804
|
+
if (throttlePending) {
|
|
1805
|
+
throttlePending = false;
|
|
1806
|
+
fire();
|
|
1807
|
+
}
|
|
1808
|
+
}, spec.throttle);
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
fire();
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// ---------------------------------------------------------------------------
|
|
1817
|
+
// WebSocket support
|
|
1818
|
+
// ---------------------------------------------------------------------------
|
|
1819
|
+
|
|
1820
|
+
function setupWebSocket(el, ctx, templateStack) {
|
|
1821
|
+
var wsUrl = el.getAttribute("xh-ws");
|
|
1822
|
+
if (!wsUrl) return;
|
|
1823
|
+
|
|
1824
|
+
var ws;
|
|
1825
|
+
try {
|
|
1826
|
+
ws = new WebSocket(wsUrl);
|
|
1827
|
+
} catch (e) {
|
|
1828
|
+
console.error("[xhtmlx] WebSocket connection failed:", wsUrl, e);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
var state = elementStates.get(el) || {};
|
|
1833
|
+
state.ws = ws;
|
|
1834
|
+
elementStates.set(el, state);
|
|
1835
|
+
|
|
1836
|
+
ws.addEventListener("message", function(event) {
|
|
1837
|
+
var jsonData;
|
|
1838
|
+
try {
|
|
1839
|
+
jsonData = JSON.parse(event.data);
|
|
1840
|
+
} catch (e) {
|
|
1841
|
+
if (config.debug) console.warn("[xhtmlx] WebSocket message is not JSON:", event.data);
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
var childCtx = new DataContext(jsonData, ctx);
|
|
1846
|
+
|
|
1847
|
+
resolveTemplate(el, templateStack).then(function(tmpl) {
|
|
1848
|
+
if (tmpl.html !== null) {
|
|
1849
|
+
var swapMode = el.getAttribute("xh-swap") || config.defaultSwapMode;
|
|
1850
|
+
var target = getSwapTarget(el, false);
|
|
1851
|
+
var fragment = renderTemplate(tmpl.html, childCtx);
|
|
1852
|
+
|
|
1853
|
+
var swapAllowed = emitEvent(el, "xh:beforeSwap", {
|
|
1854
|
+
target: target, fragment: fragment, swapMode: swapMode
|
|
1855
|
+
}, true);
|
|
1856
|
+
if (!swapAllowed) return;
|
|
1857
|
+
|
|
1858
|
+
var processTarget = performSwap(target, fragment, swapMode);
|
|
1859
|
+
if (processTarget) {
|
|
1860
|
+
processNode(processTarget, childCtx, tmpl.templateStack);
|
|
1861
|
+
}
|
|
1862
|
+
emitEvent(el, "xh:afterSwap", { target: target }, false);
|
|
1863
|
+
} else {
|
|
1864
|
+
applyBindings(el, childCtx);
|
|
1865
|
+
processBindingsInTree(el, childCtx);
|
|
1866
|
+
}
|
|
1867
|
+
});
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
ws.addEventListener("open", function() {
|
|
1871
|
+
emitEvent(el, "xh:wsOpen", { url: wsUrl }, false);
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
ws.addEventListener("close", function(event) {
|
|
1875
|
+
emitEvent(el, "xh:wsClose", { code: event.code, reason: event.reason }, false);
|
|
1876
|
+
// Auto-reconnect after 3 seconds if not deliberately closed
|
|
1877
|
+
if (event.code !== 1000) {
|
|
1878
|
+
setTimeout(function() {
|
|
1879
|
+
if (el.parentNode) setupWebSocket(el, ctx, templateStack);
|
|
1880
|
+
}, 3000);
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
ws.addEventListener("error", function() {
|
|
1885
|
+
emitEvent(el, "xh:wsError", { url: wsUrl }, false);
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// For sending messages
|
|
1890
|
+
function setupWsSend(el) {
|
|
1891
|
+
var sendTarget = el.getAttribute("xh-ws-send");
|
|
1892
|
+
if (!sendTarget) return;
|
|
1893
|
+
|
|
1894
|
+
el.addEventListener(el.tagName.toLowerCase() === "form" ? "submit" : "click", function(evt) {
|
|
1895
|
+
evt.preventDefault();
|
|
1896
|
+
var wsEl = document.querySelector(sendTarget);
|
|
1897
|
+
if (!wsEl) return;
|
|
1898
|
+
var wsState = elementStates.get(wsEl);
|
|
1899
|
+
if (!wsState || !wsState.ws || wsState.ws.readyState !== 1) return;
|
|
1900
|
+
|
|
1901
|
+
var data = {};
|
|
1902
|
+
var form = el.tagName.toLowerCase() === "form" ? el : el.closest("form");
|
|
1903
|
+
if (form) {
|
|
1904
|
+
new FormData(form).forEach(function(v, k) { data[k] = v; });
|
|
1905
|
+
}
|
|
1906
|
+
var vals = el.getAttribute("xh-vals");
|
|
1907
|
+
if (vals) {
|
|
1908
|
+
try {
|
|
1909
|
+
var parsed = JSON.parse(vals);
|
|
1910
|
+
for (var k in parsed) {
|
|
1911
|
+
if (parsed.hasOwnProperty(k)) data[k] = parsed[k];
|
|
1912
|
+
}
|
|
1913
|
+
} catch(e) {
|
|
1914
|
+
// ignore invalid JSON
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
wsState.ws.send(JSON.stringify(data));
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// ---------------------------------------------------------------------------
|
|
1922
|
+
// xh-boost — enhance regular links and forms
|
|
1923
|
+
// ---------------------------------------------------------------------------
|
|
1924
|
+
|
|
1925
|
+
function boostElement(container, ctx) {
|
|
1926
|
+
// Boost links
|
|
1927
|
+
var links = container.querySelectorAll("a[href]");
|
|
1928
|
+
for (var i = 0; i < links.length; i++) {
|
|
1929
|
+
if (links[i].hasAttribute("xh-get") || links[i].hasAttribute("data-xh-boosted")) continue;
|
|
1930
|
+
boostLink(links[i], ctx);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// Boost forms
|
|
1934
|
+
var forms = container.querySelectorAll("form[action]");
|
|
1935
|
+
for (var j = 0; j < forms.length; j++) {
|
|
1936
|
+
if (getRestVerb(forms[j]) || forms[j].hasAttribute("data-xh-boosted")) continue;
|
|
1937
|
+
boostForm(forms[j], ctx);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function boostLink(link, ctx) {
|
|
1942
|
+
var href = link.getAttribute("href");
|
|
1943
|
+
if (!href || href.indexOf("#") === 0 || href.indexOf("javascript:") === 0 || href.indexOf("mailto:") === 0 || link.getAttribute("target") === "_blank") return;
|
|
1944
|
+
|
|
1945
|
+
link.setAttribute("data-xh-boosted", "");
|
|
1946
|
+
link.addEventListener("click", function(e) {
|
|
1947
|
+
e.preventDefault();
|
|
1948
|
+
var boostContainer = link.closest("[xh-boost]");
|
|
1949
|
+
var boostTarget = boostContainer.getAttribute("xh-boost-target") || "#xh-boost-content";
|
|
1950
|
+
var target = document.querySelector(boostTarget);
|
|
1951
|
+
if (!target) target = document.body;
|
|
1952
|
+
|
|
1953
|
+
showIndicator(boostContainer);
|
|
1954
|
+
|
|
1955
|
+
fetch(href).then(function(response) {
|
|
1956
|
+
return response.text();
|
|
1957
|
+
}).then(function(text) {
|
|
1958
|
+
var jsonData;
|
|
1959
|
+
try {
|
|
1960
|
+
jsonData = JSON.parse(text);
|
|
1961
|
+
} catch(e) {
|
|
1962
|
+
// If not JSON, treat as HTML and swap directly
|
|
1963
|
+
target.innerHTML = text;
|
|
1964
|
+
processNode(target, ctx, []);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
var childCtx = new DataContext(jsonData, ctx);
|
|
1968
|
+
var templateUrl = boostContainer.getAttribute("xh-boost-template");
|
|
1969
|
+
if (templateUrl) {
|
|
1970
|
+
fetchTemplate(templateUrl).then(function(html) {
|
|
1971
|
+
var fragment = renderTemplate(html, childCtx);
|
|
1972
|
+
target.innerHTML = "";
|
|
1973
|
+
target.appendChild(fragment);
|
|
1974
|
+
processNode(target, childCtx, []);
|
|
1975
|
+
});
|
|
1976
|
+
} else {
|
|
1977
|
+
// Self-bind
|
|
1978
|
+
applyBindings(target, childCtx);
|
|
1979
|
+
processBindingsInTree(target, childCtx);
|
|
1980
|
+
}
|
|
1981
|
+
}).finally(function() {
|
|
1982
|
+
hideIndicator(boostContainer);
|
|
1983
|
+
});
|
|
1984
|
+
|
|
1985
|
+
// Push URL
|
|
1986
|
+
if (typeof history !== "undefined" && history.pushState) {
|
|
1987
|
+
history.pushState({ xhtmlx: true, url: href }, "", href);
|
|
1988
|
+
}
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
function boostForm(form, ctx) {
|
|
1993
|
+
var action = form.getAttribute("action");
|
|
1994
|
+
var method = (form.getAttribute("method") || "GET").toUpperCase();
|
|
1995
|
+
if (!action) return;
|
|
1996
|
+
|
|
1997
|
+
form.setAttribute("data-xh-boosted", "");
|
|
1998
|
+
form.addEventListener("submit", function(e) {
|
|
1999
|
+
e.preventDefault();
|
|
2000
|
+
var body = {};
|
|
2001
|
+
new FormData(form).forEach(function(v, k) { body[k] = v; });
|
|
2002
|
+
|
|
2003
|
+
var fetchOpts = { method: method, headers: {} };
|
|
2004
|
+
if (method !== "GET") {
|
|
2005
|
+
fetchOpts.headers["Content-Type"] = "application/json";
|
|
2006
|
+
fetchOpts.body = JSON.stringify(body);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
var boostContainer = form.closest("[xh-boost]");
|
|
2010
|
+
var boostTarget = boostContainer.getAttribute("xh-boost-target") || "#xh-boost-content";
|
|
2011
|
+
var target = document.querySelector(boostTarget);
|
|
2012
|
+
if (!target) target = form;
|
|
2013
|
+
|
|
2014
|
+
fetch(action, fetchOpts).then(function(response) {
|
|
2015
|
+
return response.text();
|
|
2016
|
+
}).then(function(text) {
|
|
2017
|
+
var jsonData;
|
|
2018
|
+
try { jsonData = JSON.parse(text); } catch(e) { return; }
|
|
2019
|
+
var childCtx = new DataContext(jsonData, ctx);
|
|
2020
|
+
var templateUrl = boostContainer.getAttribute("xh-boost-template");
|
|
2021
|
+
if (templateUrl) {
|
|
2022
|
+
fetchTemplate(templateUrl).then(function(html) {
|
|
2023
|
+
var fragment = renderTemplate(html, childCtx);
|
|
2024
|
+
target.innerHTML = "";
|
|
2025
|
+
target.appendChild(fragment);
|
|
2026
|
+
processNode(target, childCtx, []);
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
});
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// ---------------------------------------------------------------------------
|
|
2034
|
+
// Plugin / Extension API
|
|
2035
|
+
// ---------------------------------------------------------------------------
|
|
2036
|
+
|
|
2037
|
+
var customDirectives = []; // [{name, handler}]
|
|
2038
|
+
var globalHooks = {}; // event -> [handler]
|
|
2039
|
+
var transforms = {}; // name -> function
|
|
2040
|
+
|
|
2041
|
+
function registerDirective(name, handler) {
|
|
2042
|
+
customDirectives.push({ name: name, handler: handler });
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function registerHook(event, handler) {
|
|
2046
|
+
if (!globalHooks[event]) globalHooks[event] = [];
|
|
2047
|
+
globalHooks[event].push(handler);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
function registerTransform(name, fn) {
|
|
2051
|
+
transforms[name] = fn;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function runHooks(event, detail) {
|
|
2055
|
+
var hooks = globalHooks[event];
|
|
2056
|
+
if (!hooks) return true;
|
|
2057
|
+
for (var i = 0; i < hooks.length; i++) {
|
|
2058
|
+
var result = hooks[i](detail);
|
|
2059
|
+
if (result === false) return false;
|
|
2060
|
+
}
|
|
2061
|
+
return true;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// ---------------------------------------------------------------------------
|
|
2065
|
+
// Core processing loop
|
|
2066
|
+
// ---------------------------------------------------------------------------
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* Process a DOM node: find all descendants with xh-* attributes, attach
|
|
2070
|
+
* triggers for REST verb elements, and apply bindings for binding-only
|
|
2071
|
+
* elements.
|
|
2072
|
+
*
|
|
2073
|
+
* @param {Element} root
|
|
2074
|
+
* @param {DataContext} ctx
|
|
2075
|
+
* @param {string[]} templateStack – For circular template detection.
|
|
2076
|
+
*/
|
|
2077
|
+
function processNode(root, ctx, templateStack) {
|
|
2078
|
+
templateStack = templateStack || [];
|
|
2079
|
+
|
|
2080
|
+
if (!root || root.nodeType !== 1) return;
|
|
2081
|
+
|
|
2082
|
+
// Process the root element itself if it has xh-* attributes
|
|
2083
|
+
processElement(root, ctx, templateStack);
|
|
2084
|
+
|
|
2085
|
+
// Find all descendant elements with any xh-* attribute
|
|
2086
|
+
// Use a broad selector that catches all xh- prefixed attributes
|
|
2087
|
+
var candidates = gatherXhElements(root);
|
|
2088
|
+
|
|
2089
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
2090
|
+
var el = candidates[i];
|
|
2091
|
+
// Skip already processed elements
|
|
2092
|
+
var existingState = elementStates.get(el);
|
|
2093
|
+
if (existingState && existingState.processed) continue;
|
|
2094
|
+
// Skip detached elements
|
|
2095
|
+
if (!el.parentNode) continue;
|
|
2096
|
+
|
|
2097
|
+
processElement(el, ctx, templateStack);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
/**
|
|
2102
|
+
* Gather elements that have xh-* attributes within a root node.
|
|
2103
|
+
* @param {Element} root
|
|
2104
|
+
* @returns {Element[]}
|
|
2105
|
+
*/
|
|
2106
|
+
function gatherXhElements(root) {
|
|
2107
|
+
// We need a comprehensive selector for any element with xh-* attributes
|
|
2108
|
+
var selectors = [
|
|
2109
|
+
"[xh-get]", "[xh-post]", "[xh-put]", "[xh-delete]", "[xh-patch]",
|
|
2110
|
+
"[xh-text]", "[xh-html]", "[xh-each]", "[xh-if]", "[xh-unless]",
|
|
2111
|
+
"[xh-trigger]", "[xh-template]", "[xh-target]", "[xh-swap]",
|
|
2112
|
+
"[xh-indicator]", "[xh-vals]", "[xh-headers]",
|
|
2113
|
+
"[xh-error-template]", "[xh-error-target]",
|
|
2114
|
+
"[xh-model]", "[xh-show]", "[xh-hide]",
|
|
2115
|
+
"[xh-disabled-class]",
|
|
2116
|
+
"[xh-push-url]", "[xh-replace-url]",
|
|
2117
|
+
"[xh-cache]",
|
|
2118
|
+
"[xh-retry]",
|
|
2119
|
+
"[xh-ws]", "[xh-ws-send]",
|
|
2120
|
+
"[xh-boost]",
|
|
2121
|
+
"[xh-validate]",
|
|
2122
|
+
"[xh-i18n]",
|
|
2123
|
+
"[xh-router]", "[xh-route]",
|
|
2124
|
+
"[xh-focus]", "[xh-aria-live]"
|
|
2125
|
+
];
|
|
2126
|
+
|
|
2127
|
+
// Also match xh-attr-* and xh-error-template-*
|
|
2128
|
+
var results = [];
|
|
2129
|
+
var all;
|
|
2130
|
+
try {
|
|
2131
|
+
all = root.querySelectorAll(selectors.join(","));
|
|
2132
|
+
} catch (_) {
|
|
2133
|
+
all = root.querySelectorAll("*");
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
var seen = new Set();
|
|
2137
|
+
for (var i = 0; i < all.length; i++) {
|
|
2138
|
+
if (!seen.has(all[i])) {
|
|
2139
|
+
seen.add(all[i]);
|
|
2140
|
+
results.push(all[i]);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// Also check for xh-attr-* elements (they won't match the fixed selectors)
|
|
2145
|
+
var allEls = root.querySelectorAll("*");
|
|
2146
|
+
for (var j = 0; j < allEls.length; j++) {
|
|
2147
|
+
if (seen.has(allEls[j])) continue;
|
|
2148
|
+
var attrs = allEls[j].attributes;
|
|
2149
|
+
for (var k = 0; k < attrs.length; k++) {
|
|
2150
|
+
if (attrs[k].name.indexOf("xh-attr-") === 0 ||
|
|
2151
|
+
attrs[k].name.indexOf("xh-error-template-") === 0 ||
|
|
2152
|
+
attrs[k].name.indexOf("xh-class-") === 0 ||
|
|
2153
|
+
attrs[k].name.indexOf("xh-on-") === 0 ||
|
|
2154
|
+
attrs[k].name.indexOf("xh-i18n-") === 0) {
|
|
2155
|
+
results.push(allEls[j]);
|
|
2156
|
+
seen.add(allEls[j]);
|
|
2157
|
+
break;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
return results;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// ---------------------------------------------------------------------------
|
|
2166
|
+
// xh-on-* event handler helper
|
|
2167
|
+
// ---------------------------------------------------------------------------
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* Attach a declarative event handler for xh-on-{event} directives.
|
|
2171
|
+
*
|
|
2172
|
+
* @param {Element} el – The element to attach the handler to.
|
|
2173
|
+
* @param {string} event – The DOM event name (e.g. "click", "dblclick").
|
|
2174
|
+
* @param {string} actionStr – The action string (e.g. "toggleClass:active").
|
|
2175
|
+
*/
|
|
2176
|
+
function attachOnHandler(el, event, actionStr) {
|
|
2177
|
+
el.addEventListener(event, function(_evt) {
|
|
2178
|
+
var parts = actionStr.split(":");
|
|
2179
|
+
var action = parts[0];
|
|
2180
|
+
var arg = parts.slice(1).join(":");
|
|
2181
|
+
|
|
2182
|
+
switch (action) {
|
|
2183
|
+
case "toggleClass":
|
|
2184
|
+
el.classList.toggle(arg);
|
|
2185
|
+
break;
|
|
2186
|
+
case "addClass":
|
|
2187
|
+
el.classList.add(arg);
|
|
2188
|
+
break;
|
|
2189
|
+
case "removeClass":
|
|
2190
|
+
el.classList.remove(arg);
|
|
2191
|
+
break;
|
|
2192
|
+
case "remove":
|
|
2193
|
+
el.remove();
|
|
2194
|
+
break;
|
|
2195
|
+
case "toggle":
|
|
2196
|
+
var target = document.querySelector(arg);
|
|
2197
|
+
if (target) {
|
|
2198
|
+
target.style.display = target.style.display === "none" ? "" : "none";
|
|
2199
|
+
}
|
|
2200
|
+
break;
|
|
2201
|
+
case "dispatch":
|
|
2202
|
+
el.dispatchEvent(new CustomEvent(arg, { bubbles: true, detail: {} }));
|
|
2203
|
+
break;
|
|
2204
|
+
default:
|
|
2205
|
+
if (config.debug) console.warn("[xhtmlx] unknown xh-on action:", action);
|
|
2206
|
+
}
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* Process a single element: either attach REST triggers or apply
|
|
2212
|
+
* binding-only directives.
|
|
2213
|
+
*
|
|
2214
|
+
* @param {Element} el
|
|
2215
|
+
* @param {DataContext} ctx
|
|
2216
|
+
* @param {string[]} templateStack
|
|
2217
|
+
*/
|
|
2218
|
+
function processElement(el, ctx, templateStack) {
|
|
2219
|
+
// Skip already-processed elements to prevent double processing
|
|
2220
|
+
// (e.g. MutationObserver + explicit process() call)
|
|
2221
|
+
var existing = elementStates.get(el);
|
|
2222
|
+
if (existing && existing.processed) return;
|
|
2223
|
+
|
|
2224
|
+
// -- xh-on-* event handlers -----------------------------------------------
|
|
2225
|
+
var onAttrs = [];
|
|
2226
|
+
for (var oa = 0; oa < el.attributes.length; oa++) {
|
|
2227
|
+
if (el.attributes[oa].name.indexOf("xh-on-") === 0) {
|
|
2228
|
+
onAttrs.push({
|
|
2229
|
+
event: el.attributes[oa].name.slice(6),
|
|
2230
|
+
action: el.attributes[oa].value
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
for (var ob = 0; ob < onAttrs.length; ob++) {
|
|
2235
|
+
attachOnHandler(el, onAttrs[ob].event, onAttrs[ob].action);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
// -- i18n support -----------------------------------------------------------
|
|
2239
|
+
if (el.hasAttribute("xh-i18n") || checkElementForI18nAttr(el)) {
|
|
2240
|
+
applyI18n(el);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
if (el.hasAttribute("xh-ws")) {
|
|
2244
|
+
setupWebSocket(el, ctx, templateStack);
|
|
2245
|
+
var wState = elementStates.get(el) || {};
|
|
2246
|
+
wState.processed = true;
|
|
2247
|
+
elementStates.set(el, wState);
|
|
2248
|
+
}
|
|
2249
|
+
if (el.hasAttribute("xh-ws-send")) {
|
|
2250
|
+
setupWsSend(el);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
if (el.hasAttribute("xh-boost")) {
|
|
2254
|
+
boostElement(el, ctx);
|
|
2255
|
+
var boostState = elementStates.get(el) || {};
|
|
2256
|
+
boostState.processed = true;
|
|
2257
|
+
elementStates.set(el, boostState);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// Accessibility: auto-set aria-live on xh-target elements
|
|
2261
|
+
var targetSel = el.getAttribute("xh-target");
|
|
2262
|
+
if (targetSel) {
|
|
2263
|
+
var ariaLiveTarget = document.querySelector(targetSel);
|
|
2264
|
+
if (ariaLiveTarget && !ariaLiveTarget.hasAttribute("aria-live")) {
|
|
2265
|
+
var ariaLive = el.getAttribute("xh-aria-live") || "polite";
|
|
2266
|
+
ariaLiveTarget.setAttribute("aria-live", ariaLive);
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
var restInfo = getRestVerb(el);
|
|
2271
|
+
|
|
2272
|
+
if (restInfo) {
|
|
2273
|
+
// Initialize element state
|
|
2274
|
+
var state = existing || {};
|
|
2275
|
+
state.dataContext = ctx;
|
|
2276
|
+
state.requestInFlight = false;
|
|
2277
|
+
state.intervalIds = state.intervalIds || [];
|
|
2278
|
+
state.observers = state.observers || [];
|
|
2279
|
+
elementStates.set(el, state);
|
|
2280
|
+
|
|
2281
|
+
// Attach triggers
|
|
2282
|
+
attachTriggers(el, ctx, templateStack);
|
|
2283
|
+
} else {
|
|
2284
|
+
// Binding-only element — apply immediately
|
|
2285
|
+
// Note: xh-each elements should have been handled in renderTemplate
|
|
2286
|
+
// This handles binding-only elements found directly in the document
|
|
2287
|
+
if (el.hasAttribute("xh-each")) {
|
|
2288
|
+
processEach(el, ctx);
|
|
2289
|
+
} else {
|
|
2290
|
+
var kept = applyBindings(el, ctx);
|
|
2291
|
+
if (kept) {
|
|
2292
|
+
// Mark as processed
|
|
2293
|
+
var bState = elementStates.get(el) || {};
|
|
2294
|
+
bState.processed = true;
|
|
2295
|
+
elementStates.set(el, bState);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// ---------------------------------------------------------------------------
|
|
2302
|
+
// MutationObserver — auto-process dynamically added elements
|
|
2303
|
+
// ---------------------------------------------------------------------------
|
|
2304
|
+
|
|
2305
|
+
var mutationObserver = null;
|
|
2306
|
+
|
|
2307
|
+
function setupMutationObserver(ctx) {
|
|
2308
|
+
if (mutationObserver) return;
|
|
2309
|
+
|
|
2310
|
+
mutationObserver = new MutationObserver(function (mutations) {
|
|
2311
|
+
for (var m = 0; m < mutations.length; m++) {
|
|
2312
|
+
var added = mutations[m].addedNodes;
|
|
2313
|
+
for (var n = 0; n < added.length; n++) {
|
|
2314
|
+
var node = added[n];
|
|
2315
|
+
if (node.nodeType !== 1) continue; // Element nodes only
|
|
2316
|
+
|
|
2317
|
+
// Check if this node or any descendant has xh-* attributes
|
|
2318
|
+
var hasXh = hasXhAttributes(node);
|
|
2319
|
+
if (hasXh) {
|
|
2320
|
+
// Use the root data context
|
|
2321
|
+
processNode(node, ctx, []);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2327
|
+
mutationObserver.observe(document.body, {
|
|
2328
|
+
childList: true,
|
|
2329
|
+
subtree: true
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/**
|
|
2334
|
+
* Check whether an element or any of its descendants have xh-* attributes.
|
|
2335
|
+
* @param {Element} el
|
|
2336
|
+
* @returns {boolean}
|
|
2337
|
+
*/
|
|
2338
|
+
function hasXhAttributes(el) {
|
|
2339
|
+
// Check the element itself
|
|
2340
|
+
if (checkElementForXh(el)) return true;
|
|
2341
|
+
// Check descendants
|
|
2342
|
+
var all = el.querySelectorAll ? el.querySelectorAll("*") : [];
|
|
2343
|
+
for (var i = 0; i < all.length; i++) {
|
|
2344
|
+
if (checkElementForXh(all[i])) return true;
|
|
2345
|
+
}
|
|
2346
|
+
return false;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
/**
|
|
2350
|
+
* Check if a single element has any xh-* attribute.
|
|
2351
|
+
* @param {Element} el
|
|
2352
|
+
* @returns {boolean}
|
|
2353
|
+
*/
|
|
2354
|
+
function checkElementForXh(el) {
|
|
2355
|
+
if (!el.attributes) return false;
|
|
2356
|
+
for (var i = 0; i < el.attributes.length; i++) {
|
|
2357
|
+
if (el.attributes[i].name.indexOf("xh-") === 0) return true;
|
|
2358
|
+
}
|
|
2359
|
+
return false;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/**
|
|
2363
|
+
* Check if an element has any xh-i18n-{attr} attribute (not xh-i18n-vars).
|
|
2364
|
+
* @param {Element} el
|
|
2365
|
+
* @returns {boolean}
|
|
2366
|
+
*/
|
|
2367
|
+
function checkElementForI18nAttr(el) {
|
|
2368
|
+
if (!el.attributes) return false;
|
|
2369
|
+
for (var i = 0; i < el.attributes.length; i++) {
|
|
2370
|
+
var name = el.attributes[i].name;
|
|
2371
|
+
if (name.indexOf("xh-i18n-") === 0 && name !== "xh-i18n-vars") return true;
|
|
2372
|
+
}
|
|
2373
|
+
return false;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// ---------------------------------------------------------------------------
|
|
2377
|
+
// i18n — Internationalization support
|
|
2378
|
+
// ---------------------------------------------------------------------------
|
|
2379
|
+
|
|
2380
|
+
var i18n = {
|
|
2381
|
+
_locales: {},
|
|
2382
|
+
_locale: null,
|
|
2383
|
+
_fallback: "en",
|
|
2384
|
+
|
|
2385
|
+
load: function(locale, translations) {
|
|
2386
|
+
i18n._locales[locale] = i18n._locales[locale] || {};
|
|
2387
|
+
for (var k in translations) {
|
|
2388
|
+
if (translations.hasOwnProperty(k)) {
|
|
2389
|
+
i18n._locales[locale][k] = translations[k];
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
},
|
|
2393
|
+
|
|
2394
|
+
get locale() { return i18n._locale || i18n._fallback; },
|
|
2395
|
+
set locale(val) {
|
|
2396
|
+
i18n._locale = val;
|
|
2397
|
+
if (typeof document !== "undefined") {
|
|
2398
|
+
applyI18n(document.body);
|
|
2399
|
+
emitEvent(document.body, "xh:localeChanged", { locale: val }, false);
|
|
2400
|
+
}
|
|
2401
|
+
},
|
|
2402
|
+
|
|
2403
|
+
t: function(key, vars) {
|
|
2404
|
+
var locales = [i18n._locale, i18n._fallback];
|
|
2405
|
+
for (var l = 0; l < locales.length; l++) {
|
|
2406
|
+
if (!locales[l]) continue;
|
|
2407
|
+
var dict = i18n._locales[locales[l]];
|
|
2408
|
+
if (dict && dict[key] != null) {
|
|
2409
|
+
var text = String(dict[key]);
|
|
2410
|
+
if (vars) {
|
|
2411
|
+
for (var v in vars) {
|
|
2412
|
+
if (vars.hasOwnProperty(v)) {
|
|
2413
|
+
text = text.replace(new RegExp("\\{" + v + "\\}", "g"), vars[v]);
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
return text;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
return key; // fallback to key itself
|
|
2421
|
+
}
|
|
2422
|
+
};
|
|
2423
|
+
|
|
2424
|
+
/**
|
|
2425
|
+
* Apply i18n translations to elements with xh-i18n and xh-i18n-{attr} attributes.
|
|
2426
|
+
*
|
|
2427
|
+
* @param {Element} root – The root element to scan.
|
|
2428
|
+
*/
|
|
2429
|
+
function applyI18n(root) {
|
|
2430
|
+
var els = root.querySelectorAll("[xh-i18n]");
|
|
2431
|
+
for (var i = 0; i < els.length; i++) {
|
|
2432
|
+
var key = els[i].getAttribute("xh-i18n");
|
|
2433
|
+
var vars = els[i].getAttribute("xh-i18n-vars");
|
|
2434
|
+
var parsedVars = null;
|
|
2435
|
+
if (vars) {
|
|
2436
|
+
try { parsedVars = JSON.parse(vars); } catch(e) { /* ignore */ }
|
|
2437
|
+
}
|
|
2438
|
+
els[i].textContent = i18n.t(key, parsedVars);
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// xh-i18n-{attr} for attribute translations
|
|
2442
|
+
var all = root.querySelectorAll("*");
|
|
2443
|
+
for (var j = 0; j < all.length; j++) {
|
|
2444
|
+
var attrs = all[j].attributes;
|
|
2445
|
+
for (var a = 0; a < attrs.length; a++) {
|
|
2446
|
+
var name = attrs[a].name;
|
|
2447
|
+
if (name.indexOf("xh-i18n-") === 0 && name !== "xh-i18n-vars") {
|
|
2448
|
+
var targetAttr = name.slice(8);
|
|
2449
|
+
var attrKey = attrs[a].value;
|
|
2450
|
+
all[j].setAttribute(targetAttr, i18n.t(attrKey));
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
// ---------------------------------------------------------------------------
|
|
2457
|
+
// SPA Router
|
|
2458
|
+
// ---------------------------------------------------------------------------
|
|
2459
|
+
|
|
2460
|
+
var router = {
|
|
2461
|
+
_routes: [],
|
|
2462
|
+
_outlet: null,
|
|
2463
|
+
_activeLink: null,
|
|
2464
|
+
_activeClass: "xh-route-active",
|
|
2465
|
+
_notFoundTemplate: null,
|
|
2466
|
+
|
|
2467
|
+
_init: function() {
|
|
2468
|
+
// Scan for xh-router containers
|
|
2469
|
+
if (typeof document === "undefined") return;
|
|
2470
|
+
var containers = document.querySelectorAll("[xh-router]");
|
|
2471
|
+
for (var c = 0; c < containers.length; c++) {
|
|
2472
|
+
var outlet = containers[c].getAttribute("xh-router-outlet") || "#router-outlet";
|
|
2473
|
+
router._outlet = document.querySelector(outlet);
|
|
2474
|
+
|
|
2475
|
+
var links = containers[c].querySelectorAll("[xh-route]");
|
|
2476
|
+
for (var l = 0; l < links.length; l++) {
|
|
2477
|
+
var link = links[l];
|
|
2478
|
+
var route = {
|
|
2479
|
+
path: link.getAttribute("xh-route"),
|
|
2480
|
+
template: link.getAttribute("xh-template"),
|
|
2481
|
+
api: link.getAttribute("xh-get"),
|
|
2482
|
+
element: link,
|
|
2483
|
+
regex: null,
|
|
2484
|
+
paramNames: []
|
|
2485
|
+
};
|
|
2486
|
+
|
|
2487
|
+
// Convert path pattern to regex: /users/:id -> /users/([^/]+)
|
|
2488
|
+
var paramNames = [];
|
|
2489
|
+
var regexStr = route.path.replace(/:([^/]+)/g, function(_, name) {
|
|
2490
|
+
paramNames.push(name);
|
|
2491
|
+
return "([^/]+)";
|
|
2492
|
+
});
|
|
2493
|
+
route.regex = new RegExp("^" + regexStr + "$");
|
|
2494
|
+
route.paramNames = paramNames;
|
|
2495
|
+
router._routes.push(route);
|
|
2496
|
+
|
|
2497
|
+
// Click handler
|
|
2498
|
+
(function(r) {
|
|
2499
|
+
r.element.addEventListener("click", function(e) {
|
|
2500
|
+
e.preventDefault();
|
|
2501
|
+
router.navigate(r.path);
|
|
2502
|
+
});
|
|
2503
|
+
})(route);
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// 404 fallback
|
|
2507
|
+
var notFound = containers[c].getAttribute("xh-router-404");
|
|
2508
|
+
if (notFound) router._notFoundTemplate = notFound;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
// Handle popstate for back/forward
|
|
2512
|
+
window.addEventListener("popstate", function() {
|
|
2513
|
+
router._resolve(window.location.pathname);
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
// Resolve current URL on init
|
|
2517
|
+
if (router._routes.length > 0) {
|
|
2518
|
+
router._resolve(window.location.pathname);
|
|
2519
|
+
}
|
|
2520
|
+
},
|
|
2521
|
+
|
|
2522
|
+
navigate: function(path) {
|
|
2523
|
+
history.pushState({ xhtmlx: true, route: path }, "", path);
|
|
2524
|
+
router._resolve(path);
|
|
2525
|
+
},
|
|
2526
|
+
|
|
2527
|
+
_resolve: function(path) {
|
|
2528
|
+
if (!router._outlet) return;
|
|
2529
|
+
|
|
2530
|
+
for (var i = 0; i < router._routes.length; i++) {
|
|
2531
|
+
var route = router._routes[i];
|
|
2532
|
+
var match = path.match(route.regex);
|
|
2533
|
+
if (match) {
|
|
2534
|
+
// Extract params
|
|
2535
|
+
var params = {};
|
|
2536
|
+
for (var p = 0; p < route.paramNames.length; p++) {
|
|
2537
|
+
params[route.paramNames[p]] = match[p + 1];
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// Update active class
|
|
2541
|
+
if (router._activeLink) {
|
|
2542
|
+
router._activeLink.classList.remove(router._activeClass);
|
|
2543
|
+
}
|
|
2544
|
+
route.element.classList.add(router._activeClass);
|
|
2545
|
+
router._activeLink = route.element;
|
|
2546
|
+
|
|
2547
|
+
// Fetch data and render
|
|
2548
|
+
var ctx = new DataContext(params);
|
|
2549
|
+
|
|
2550
|
+
if (route.api) {
|
|
2551
|
+
var url = interpolate(route.api, ctx, true);
|
|
2552
|
+
fetch(url).then(function(r) { return r.text(); }).then(function(text) {
|
|
2553
|
+
var data;
|
|
2554
|
+
try { data = JSON.parse(text); } catch(e) { data = {}; }
|
|
2555
|
+
var childCtx = new DataContext(data, ctx);
|
|
2556
|
+
|
|
2557
|
+
if (route.template) {
|
|
2558
|
+
fetchTemplate(route.template).then(function(html) {
|
|
2559
|
+
var fragment = renderTemplate(html, childCtx);
|
|
2560
|
+
router._outlet.innerHTML = "";
|
|
2561
|
+
router._outlet.appendChild(fragment);
|
|
2562
|
+
processNode(router._outlet, childCtx, []);
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
});
|
|
2566
|
+
} else if (route.template) {
|
|
2567
|
+
fetchTemplate(route.template).then(function(html) {
|
|
2568
|
+
var fragment = renderTemplate(html, ctx);
|
|
2569
|
+
router._outlet.innerHTML = "";
|
|
2570
|
+
router._outlet.appendChild(fragment);
|
|
2571
|
+
processNode(router._outlet, ctx, []);
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
emitEvent(router._outlet, "xh:routeChanged", { path: path, params: params }, false);
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
// 404
|
|
2581
|
+
if (router._notFoundTemplate) {
|
|
2582
|
+
var ctx404 = new DataContext({ path: path });
|
|
2583
|
+
fetchTemplate(router._notFoundTemplate).then(function(html) {
|
|
2584
|
+
var fragment = renderTemplate(html, ctx404);
|
|
2585
|
+
router._outlet.innerHTML = "";
|
|
2586
|
+
router._outlet.appendChild(fragment);
|
|
2587
|
+
});
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
emitEvent(document.body, "xh:routeNotFound", { path: path }, false);
|
|
2591
|
+
}
|
|
2592
|
+
};
|
|
2593
|
+
|
|
2594
|
+
// ---------------------------------------------------------------------------
|
|
2595
|
+
// Public API
|
|
2596
|
+
// ---------------------------------------------------------------------------
|
|
2597
|
+
|
|
2598
|
+
var xhtmlx = {
|
|
2599
|
+
/** Library configuration */
|
|
2600
|
+
config: config,
|
|
2601
|
+
|
|
2602
|
+
/**
|
|
2603
|
+
* Manually process a DOM node and its descendants.
|
|
2604
|
+
* @param {Element} root – Element to process.
|
|
2605
|
+
* @param {DataContext} ctx – Optional data context.
|
|
2606
|
+
*/
|
|
2607
|
+
process: function (root, ctx) {
|
|
2608
|
+
processNode(root || document.body, ctx || new DataContext({}), []);
|
|
2609
|
+
},
|
|
2610
|
+
|
|
2611
|
+
/**
|
|
2612
|
+
* Create a DataContext for programmatic use.
|
|
2613
|
+
* @param {*} data
|
|
2614
|
+
* @param {DataContext} parent
|
|
2615
|
+
* @param {number} index
|
|
2616
|
+
* @returns {DataContext}
|
|
2617
|
+
*/
|
|
2618
|
+
createContext: function (data, parent, index) {
|
|
2619
|
+
return new DataContext(data, parent, index);
|
|
2620
|
+
},
|
|
2621
|
+
|
|
2622
|
+
/**
|
|
2623
|
+
* Clear the template cache.
|
|
2624
|
+
*/
|
|
2625
|
+
clearTemplateCache: function () {
|
|
2626
|
+
templateCache.clear();
|
|
2627
|
+
},
|
|
2628
|
+
|
|
2629
|
+
/**
|
|
2630
|
+
* Clear the response cache.
|
|
2631
|
+
*/
|
|
2632
|
+
clearResponseCache: function () {
|
|
2633
|
+
responseCache.clear();
|
|
2634
|
+
},
|
|
2635
|
+
|
|
2636
|
+
/**
|
|
2637
|
+
* Interpolate a string using a data context.
|
|
2638
|
+
* @param {string} str
|
|
2639
|
+
* @param {DataContext} ctx
|
|
2640
|
+
* @param {boolean} uriEncode
|
|
2641
|
+
* @returns {string}
|
|
2642
|
+
*/
|
|
2643
|
+
interpolate: function (str, ctx, uriEncode) {
|
|
2644
|
+
return interpolate(str, ctx, !!uriEncode);
|
|
2645
|
+
},
|
|
2646
|
+
|
|
2647
|
+
/** Register a custom directive processed in applyBindings. */
|
|
2648
|
+
directive: registerDirective,
|
|
2649
|
+
|
|
2650
|
+
/** Register a global hook (e.g. "beforeRequest"). */
|
|
2651
|
+
hook: registerHook,
|
|
2652
|
+
|
|
2653
|
+
/** Register a named transform for pipe syntax in bindings. */
|
|
2654
|
+
transform: registerTransform,
|
|
2655
|
+
|
|
2656
|
+
/**
|
|
2657
|
+
* Switch UI version. Sets template and API prefixes, clears all caches.
|
|
2658
|
+
* Version can be any string: "v2", "abc123", "20260315", a git SHA, etc.
|
|
2659
|
+
*
|
|
2660
|
+
* @param {string} version – Version identifier.
|
|
2661
|
+
* @param {Object} [opts] – Options.
|
|
2662
|
+
* @param {string} [opts.templatePrefix] – Template prefix. Defaults to "/ui/{version}".
|
|
2663
|
+
* @param {string} [opts.apiPrefix] – API prefix. Defaults to "" (unchanged).
|
|
2664
|
+
* @param {boolean} [opts.reload] – Re-render all active widgets. Defaults to true.
|
|
2665
|
+
*/
|
|
2666
|
+
switchVersion: function (version, opts) {
|
|
2667
|
+
opts = opts || {};
|
|
2668
|
+
config.uiVersion = version;
|
|
2669
|
+
config.templatePrefix = opts.templatePrefix != null ? opts.templatePrefix : "/ui/" + version;
|
|
2670
|
+
config.apiPrefix = opts.apiPrefix != null ? opts.apiPrefix : config.apiPrefix;
|
|
2671
|
+
templateCache.clear();
|
|
2672
|
+
responseCache.clear();
|
|
2673
|
+
|
|
2674
|
+
if (typeof document !== "undefined") {
|
|
2675
|
+
emitEvent(document.body, "xh:versionChanged", {
|
|
2676
|
+
version: version,
|
|
2677
|
+
templatePrefix: config.templatePrefix,
|
|
2678
|
+
apiPrefix: config.apiPrefix
|
|
2679
|
+
}, false);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
if (opts.reload !== false) {
|
|
2683
|
+
this.reload();
|
|
2684
|
+
}
|
|
2685
|
+
},
|
|
2686
|
+
|
|
2687
|
+
/**
|
|
2688
|
+
* Re-render all active widgets, or only those using a specific template.
|
|
2689
|
+
* Re-fetches data from API and re-renders with (possibly new) templates.
|
|
2690
|
+
*
|
|
2691
|
+
* @param {string} [templateUrl] – If provided, only reload widgets using this template.
|
|
2692
|
+
*/
|
|
2693
|
+
reload: function (templateUrl) {
|
|
2694
|
+
if (typeof document === "undefined") return;
|
|
2695
|
+
var allEls = gatherXhElements(document.body);
|
|
2696
|
+
for (var i = 0; i < allEls.length; i++) {
|
|
2697
|
+
var el = allEls[i];
|
|
2698
|
+
var st = elementStates.get(el);
|
|
2699
|
+
if (!st || !st.apiUrl) continue;
|
|
2700
|
+
if (templateUrl && st.templateUrl !== templateUrl) continue;
|
|
2701
|
+
// Reset processed flag so element can be re-triggered
|
|
2702
|
+
st.processed = false;
|
|
2703
|
+
elementStates.set(el, st);
|
|
2704
|
+
// Re-execute the request
|
|
2705
|
+
var restInfo = getRestVerb(el);
|
|
2706
|
+
if (restInfo) {
|
|
2707
|
+
var ctx = st.dataContext || new DataContext({});
|
|
2708
|
+
executeRequest(el, ctx, []);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
},
|
|
2712
|
+
|
|
2713
|
+
/** i18n module for internationalization support */
|
|
2714
|
+
i18n: i18n,
|
|
2715
|
+
|
|
2716
|
+
/** SPA Router */
|
|
2717
|
+
router: router,
|
|
2718
|
+
|
|
2719
|
+
/** Internal version string */
|
|
2720
|
+
version: "0.2.0",
|
|
2721
|
+
|
|
2722
|
+
// --- Internals exposed for testing (not part of the public API) ----------
|
|
2723
|
+
_internals: {
|
|
2724
|
+
DataContext: DataContext,
|
|
2725
|
+
MutableDataContext: MutableDataContext,
|
|
2726
|
+
interpolate: interpolate,
|
|
2727
|
+
parseTrigger: parseTrigger,
|
|
2728
|
+
parseTimeValue: parseTimeValue,
|
|
2729
|
+
renderTemplate: renderTemplate,
|
|
2730
|
+
applyBindings: applyBindings,
|
|
2731
|
+
processEach: processEach,
|
|
2732
|
+
processBindingsInTree: processBindingsInTree,
|
|
2733
|
+
processElement: processElement,
|
|
2734
|
+
attachOnHandler: attachOnHandler,
|
|
2735
|
+
executeRequest: executeRequest,
|
|
2736
|
+
resolveErrorTemplate: resolveErrorTemplate,
|
|
2737
|
+
findErrorBoundary: findErrorBoundary,
|
|
2738
|
+
getRestVerb: getRestVerb,
|
|
2739
|
+
performSwap: performSwap,
|
|
2740
|
+
buildRequestBody: buildRequestBody,
|
|
2741
|
+
fetchTemplate: fetchTemplate,
|
|
2742
|
+
resolveTemplate: resolveTemplate,
|
|
2743
|
+
getSwapTarget: getSwapTarget,
|
|
2744
|
+
defaultTrigger: defaultTrigger,
|
|
2745
|
+
resolveDot: resolveDot,
|
|
2746
|
+
templateCache: templateCache,
|
|
2747
|
+
responseCache: responseCache,
|
|
2748
|
+
elementStates: elementStates,
|
|
2749
|
+
generationMap: generationMap,
|
|
2750
|
+
fetchWithRetry: fetchWithRetry,
|
|
2751
|
+
applySettleClasses: applySettleClasses,
|
|
2752
|
+
setupWebSocket: setupWebSocket,
|
|
2753
|
+
setupWsSend: setupWsSend,
|
|
2754
|
+
boostElement: boostElement,
|
|
2755
|
+
boostLink: boostLink,
|
|
2756
|
+
boostForm: boostForm,
|
|
2757
|
+
customDirectives: customDirectives,
|
|
2758
|
+
globalHooks: globalHooks,
|
|
2759
|
+
transforms: transforms,
|
|
2760
|
+
runHooks: runHooks,
|
|
2761
|
+
registerDirective: registerDirective,
|
|
2762
|
+
registerHook: registerHook,
|
|
2763
|
+
registerTransform: registerTransform,
|
|
2764
|
+
config: config,
|
|
2765
|
+
validateElement: validateElement,
|
|
2766
|
+
applyI18n: applyI18n,
|
|
2767
|
+
i18n: i18n,
|
|
2768
|
+
router: router
|
|
2769
|
+
}
|
|
2770
|
+
};
|
|
2771
|
+
|
|
2772
|
+
// Expose globally (browser) or as module (Node/test)
|
|
2773
|
+
if (typeof window !== "undefined") {
|
|
2774
|
+
window.xhtmlx = xhtmlx;
|
|
2775
|
+
}
|
|
2776
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
2777
|
+
module.exports = xhtmlx;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
// ---------------------------------------------------------------------------
|
|
2781
|
+
// Auto-init on DOMContentLoaded (browser only)
|
|
2782
|
+
// ---------------------------------------------------------------------------
|
|
2783
|
+
|
|
2784
|
+
if (typeof document !== "undefined" && document.addEventListener) {
|
|
2785
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
2786
|
+
injectDefaultCSS();
|
|
2787
|
+
var rootCtx = new DataContext({});
|
|
2788
|
+
processNode(document.body, rootCtx, []);
|
|
2789
|
+
setupMutationObserver(rootCtx);
|
|
2790
|
+
router._init();
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// ---------------------------------------------------------------------------
|
|
2795
|
+
// popstate listener — browser history back/forward (xh-push-url support)
|
|
2796
|
+
// ---------------------------------------------------------------------------
|
|
2797
|
+
|
|
2798
|
+
if (typeof window !== "undefined") {
|
|
2799
|
+
window.addEventListener("popstate", function (e) {
|
|
2800
|
+
if (e.state && e.state.xhtmlx && e.state.url) {
|
|
2801
|
+
var target = e.state.targetSel ? document.querySelector(e.state.targetSel) : document.body;
|
|
2802
|
+
if (target) {
|
|
2803
|
+
fetch(e.state.url).then(function (r) { return r.text(); }).then(function (text) {
|
|
2804
|
+
var data;
|
|
2805
|
+
try {
|
|
2806
|
+
data = JSON.parse(text);
|
|
2807
|
+
} catch (_) {
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
var ctx = new DataContext(data);
|
|
2811
|
+
if (e.state.templateUrl) {
|
|
2812
|
+
fetchTemplate(e.state.templateUrl).then(function (html) {
|
|
2813
|
+
var fragment = renderTemplate(html, ctx);
|
|
2814
|
+
target.innerHTML = "";
|
|
2815
|
+
target.appendChild(fragment);
|
|
2816
|
+
processNode(target, ctx, []);
|
|
2817
|
+
});
|
|
2818
|
+
}
|
|
2819
|
+
}).catch(function () {});
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
});
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
})();
|