@erickxavier/no-js 1.0.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 +159 -0
- package/dist/cjs/no.js +25 -0
- package/dist/cjs/no.js.map +7 -0
- package/dist/esm/no.js +25 -0
- package/dist/esm/no.js.map +7 -0
- package/dist/iife/no.js +25 -0
- package/dist/iife/no.js.map +7 -0
- package/package.json +64 -0
- package/src/animations.js +113 -0
- package/src/cdn.js +16 -0
- package/src/context.js +104 -0
- package/src/directives/binding.js +118 -0
- package/src/directives/conditionals.js +283 -0
- package/src/directives/events.js +153 -0
- package/src/directives/http.js +288 -0
- package/src/directives/i18n.js +29 -0
- package/src/directives/loops.js +235 -0
- package/src/directives/refs.js +144 -0
- package/src/directives/state.js +102 -0
- package/src/directives/styling.js +88 -0
- package/src/directives/validation.js +216 -0
- package/src/dom.js +232 -0
- package/src/evaluate.js +298 -0
- package/src/fetch.js +173 -0
- package/src/filters.js +137 -0
- package/src/globals.js +58 -0
- package/src/i18n.js +36 -0
- package/src/index.js +189 -0
- package/src/registry.js +60 -0
- package/src/router.js +253 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
// DIRECTIVES: on:*, trigger
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { evaluate, _execStatement } from "../evaluate.js";
|
|
6
|
+
import { findContext } from "../dom.js";
|
|
7
|
+
import { registerDirective } from "../registry.js";
|
|
8
|
+
|
|
9
|
+
registerDirective("on:*", {
|
|
10
|
+
priority: 20,
|
|
11
|
+
init(el, name, expr) {
|
|
12
|
+
const ctx = findContext(el);
|
|
13
|
+
const parts = name.replace("on:", "").split(".");
|
|
14
|
+
const event = parts[0];
|
|
15
|
+
const modifiers = new Set(parts.slice(1));
|
|
16
|
+
|
|
17
|
+
// Lifecycle hooks
|
|
18
|
+
if (event === "mounted") {
|
|
19
|
+
requestAnimationFrame(() => _execStatement(expr, ctx, { $el: el }));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (event === "init") {
|
|
23
|
+
_execStatement(expr, ctx, { $el: el });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (event === "updated") {
|
|
27
|
+
const updatedObserver = new MutationObserver(() => {
|
|
28
|
+
_execStatement(expr, ctx, { $el: el });
|
|
29
|
+
});
|
|
30
|
+
updatedObserver.observe(el, { childList: true, subtree: true, characterData: true, attributes: true });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (event === "error") {
|
|
34
|
+
window.addEventListener("error", (e) => {
|
|
35
|
+
if (el.contains(e.target) || e.target === el) {
|
|
36
|
+
_execStatement(expr, ctx, { $el: el, $error: e.error || e.message });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (event === "unmounted") {
|
|
42
|
+
const observer = new MutationObserver((mutations) => {
|
|
43
|
+
for (const m of mutations) {
|
|
44
|
+
for (const node of m.removedNodes) {
|
|
45
|
+
if (node === el || node.contains?.(el)) {
|
|
46
|
+
_execStatement(expr, ctx, { $el: el });
|
|
47
|
+
observer.disconnect();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
if (el.parentElement)
|
|
54
|
+
observer.observe(el.parentElement, { childList: true });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Debounce / throttle
|
|
59
|
+
let debounceMs = 0;
|
|
60
|
+
let throttleMs = 0;
|
|
61
|
+
for (const mod of modifiers) {
|
|
62
|
+
if (/^\d+$/.test(mod)) {
|
|
63
|
+
if (modifiers.has("debounce")) debounceMs = parseInt(mod);
|
|
64
|
+
else if (modifiers.has("throttle")) throttleMs = parseInt(mod);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let handler = (e) => {
|
|
69
|
+
// Key modifiers
|
|
70
|
+
if (event === "keydown" || event === "keyup" || event === "keypress") {
|
|
71
|
+
const keyMods = [
|
|
72
|
+
"enter",
|
|
73
|
+
"escape",
|
|
74
|
+
"tab",
|
|
75
|
+
"space",
|
|
76
|
+
"delete",
|
|
77
|
+
"backspace",
|
|
78
|
+
"up",
|
|
79
|
+
"down",
|
|
80
|
+
"left",
|
|
81
|
+
"right",
|
|
82
|
+
"ctrl",
|
|
83
|
+
"alt",
|
|
84
|
+
"shift",
|
|
85
|
+
"meta",
|
|
86
|
+
];
|
|
87
|
+
for (const mod of modifiers) {
|
|
88
|
+
if (!keyMods.includes(mod)) continue;
|
|
89
|
+
if (mod === "enter" && e.key !== "Enter") return;
|
|
90
|
+
if (mod === "escape" && e.key !== "Escape") return;
|
|
91
|
+
if (mod === "tab" && e.key !== "Tab") return;
|
|
92
|
+
if (mod === "space" && e.key !== " ") return;
|
|
93
|
+
if (mod === "delete" && e.key !== "Delete" && e.key !== "Backspace")
|
|
94
|
+
return;
|
|
95
|
+
if (mod === "up" && e.key !== "ArrowUp") return;
|
|
96
|
+
if (mod === "down" && e.key !== "ArrowDown") return;
|
|
97
|
+
if (mod === "left" && e.key !== "ArrowLeft") return;
|
|
98
|
+
if (mod === "right" && e.key !== "ArrowRight") return;
|
|
99
|
+
if (mod === "ctrl" && !e.ctrlKey) return;
|
|
100
|
+
if (mod === "alt" && !e.altKey) return;
|
|
101
|
+
if (mod === "shift" && !e.shiftKey) return;
|
|
102
|
+
if (mod === "meta" && !e.metaKey) return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (modifiers.has("prevent")) e.preventDefault();
|
|
107
|
+
if (modifiers.has("stop")) e.stopPropagation();
|
|
108
|
+
if (modifiers.has("self") && e.target !== el) return;
|
|
109
|
+
|
|
110
|
+
_execStatement(expr, ctx, { $event: e, $el: el });
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Wrap with debounce
|
|
114
|
+
if (debounceMs > 0) {
|
|
115
|
+
const original = handler;
|
|
116
|
+
let timer;
|
|
117
|
+
handler = (e) => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
timer = setTimeout(() => original(e), debounceMs);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Wrap with throttle
|
|
124
|
+
if (throttleMs > 0) {
|
|
125
|
+
const original = handler;
|
|
126
|
+
let last = 0;
|
|
127
|
+
handler = (e) => {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
if (now - last >= throttleMs) {
|
|
130
|
+
last = now;
|
|
131
|
+
original(e);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const opts = {};
|
|
137
|
+
if (modifiers.has("once")) opts.once = true;
|
|
138
|
+
|
|
139
|
+
el.addEventListener(event, handler, opts);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
registerDirective("trigger", {
|
|
144
|
+
priority: 20,
|
|
145
|
+
init(el, name, eventName) {
|
|
146
|
+
const ctx = findContext(el);
|
|
147
|
+
const dataExpr = el.getAttribute("trigger-data");
|
|
148
|
+
el.addEventListener("click", () => {
|
|
149
|
+
const detail = dataExpr ? evaluate(dataExpr, ctx) : null;
|
|
150
|
+
el.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true }));
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
// DIRECTIVES: get, post, put, patch, delete
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
_config,
|
|
7
|
+
_log,
|
|
8
|
+
_warn,
|
|
9
|
+
_stores,
|
|
10
|
+
_notifyStoreWatchers,
|
|
11
|
+
_emitEvent,
|
|
12
|
+
_routerInstance,
|
|
13
|
+
} from "../globals.js";
|
|
14
|
+
import { createContext } from "../context.js";
|
|
15
|
+
import { evaluate, _execStatement, _interpolate } from "../evaluate.js";
|
|
16
|
+
import { _doFetch, _cacheGet, _cacheSet } from "../fetch.js";
|
|
17
|
+
import { findContext, _clearDeclared, _cloneTemplate } from "../dom.js";
|
|
18
|
+
import { registerDirective, processTree } from "../registry.js";
|
|
19
|
+
|
|
20
|
+
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
21
|
+
|
|
22
|
+
for (const method of HTTP_METHODS) {
|
|
23
|
+
registerDirective(method, {
|
|
24
|
+
priority: 1,
|
|
25
|
+
init(el, name, url) {
|
|
26
|
+
const asKey = el.getAttribute("as") || "data";
|
|
27
|
+
const loadingTpl = el.getAttribute("loading");
|
|
28
|
+
const errorTpl = el.getAttribute("error");
|
|
29
|
+
const emptyTpl = el.getAttribute("empty");
|
|
30
|
+
const successTpl = el.getAttribute("success");
|
|
31
|
+
const thenExpr = el.getAttribute("then");
|
|
32
|
+
const redirectPath = el.getAttribute("redirect");
|
|
33
|
+
const confirmMsg = el.getAttribute("confirm");
|
|
34
|
+
const refreshInterval = parseInt(el.getAttribute("refresh")) || 0;
|
|
35
|
+
const cachedRaw = el.getAttribute("cached");
|
|
36
|
+
const cacheStrategy = el.hasAttribute("cached")
|
|
37
|
+
? cachedRaw || "memory"
|
|
38
|
+
: "none";
|
|
39
|
+
const bodyAttr = el.getAttribute("body");
|
|
40
|
+
const headersAttr = el.getAttribute("headers");
|
|
41
|
+
const varName = el.getAttribute("var");
|
|
42
|
+
const intoStore = el.getAttribute("into");
|
|
43
|
+
const retryCount =
|
|
44
|
+
parseInt(el.getAttribute("retry")) || _config.retries;
|
|
45
|
+
const retryDelay =
|
|
46
|
+
parseInt(el.getAttribute("retry-delay")) || _config.retryDelay || 1000;
|
|
47
|
+
const paramsAttr = el.getAttribute("params");
|
|
48
|
+
|
|
49
|
+
const parentCtx = el.parentElement
|
|
50
|
+
? findContext(el.parentElement)
|
|
51
|
+
: createContext();
|
|
52
|
+
const ctx = el.__ctx || createContext({}, parentCtx);
|
|
53
|
+
el.__ctx = ctx;
|
|
54
|
+
|
|
55
|
+
const originalChildren = [...el.childNodes].map((n) =>
|
|
56
|
+
n.cloneNode(true),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
let _activeAbort = null;
|
|
60
|
+
|
|
61
|
+
async function doRequest() {
|
|
62
|
+
// SwitchMap: abort previous in-flight request
|
|
63
|
+
if (_activeAbort) _activeAbort.abort();
|
|
64
|
+
_activeAbort = new AbortController();
|
|
65
|
+
|
|
66
|
+
// Confirmation
|
|
67
|
+
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
|
68
|
+
|
|
69
|
+
let resolvedUrl = _interpolate(url, ctx);
|
|
70
|
+
|
|
71
|
+
// Append query params
|
|
72
|
+
if (paramsAttr) {
|
|
73
|
+
const paramsObj = evaluate(paramsAttr, ctx);
|
|
74
|
+
if (paramsObj && typeof paramsObj === "object") {
|
|
75
|
+
const sep = resolvedUrl.includes("?") ? "&" : "?";
|
|
76
|
+
const qs = new URLSearchParams(paramsObj).toString();
|
|
77
|
+
if (qs) resolvedUrl += sep + qs;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const cacheKey = method + ":" + resolvedUrl;
|
|
82
|
+
|
|
83
|
+
// Check cache
|
|
84
|
+
if (method === "get") {
|
|
85
|
+
const cached = _cacheGet(cacheKey, cacheStrategy);
|
|
86
|
+
if (cached != null) {
|
|
87
|
+
ctx.$set(asKey, cached);
|
|
88
|
+
_clearDeclared(el);
|
|
89
|
+
processTree(el);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Show loading
|
|
95
|
+
if (loadingTpl) {
|
|
96
|
+
const clone = _cloneTemplate(loadingTpl);
|
|
97
|
+
if (clone) {
|
|
98
|
+
el.innerHTML = "";
|
|
99
|
+
el.appendChild(clone);
|
|
100
|
+
processTree(el);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
let reqBody = null;
|
|
106
|
+
if (bodyAttr) {
|
|
107
|
+
const interpolated = _interpolate(bodyAttr, ctx);
|
|
108
|
+
try {
|
|
109
|
+
reqBody = JSON.parse(interpolated);
|
|
110
|
+
} catch {
|
|
111
|
+
reqBody = interpolated;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// For forms, collect form data
|
|
116
|
+
if (el.tagName === "FORM") {
|
|
117
|
+
const formData = new FormData(el);
|
|
118
|
+
reqBody = Object.fromEntries(formData.entries());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const extraHeaders = headersAttr ? JSON.parse(headersAttr) : {};
|
|
122
|
+
const savedRetries = _config.retries;
|
|
123
|
+
const savedRetryDelay = _config.retryDelay;
|
|
124
|
+
_config.retries = retryCount;
|
|
125
|
+
_config.retryDelay = retryDelay;
|
|
126
|
+
const data = await _doFetch(
|
|
127
|
+
resolvedUrl,
|
|
128
|
+
method,
|
|
129
|
+
reqBody,
|
|
130
|
+
extraHeaders,
|
|
131
|
+
el,
|
|
132
|
+
_activeAbort.signal,
|
|
133
|
+
);
|
|
134
|
+
_config.retries = savedRetries;
|
|
135
|
+
_config.retryDelay = savedRetryDelay;
|
|
136
|
+
|
|
137
|
+
// Cache response
|
|
138
|
+
if (method === "get") _cacheSet(cacheKey, data, cacheStrategy);
|
|
139
|
+
|
|
140
|
+
// Check empty
|
|
141
|
+
if (
|
|
142
|
+
emptyTpl &&
|
|
143
|
+
(data == null || (Array.isArray(data) && data.length === 0))
|
|
144
|
+
) {
|
|
145
|
+
const clone = _cloneTemplate(emptyTpl);
|
|
146
|
+
if (clone) {
|
|
147
|
+
el.innerHTML = "";
|
|
148
|
+
el.appendChild(clone);
|
|
149
|
+
processTree(el);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
ctx.$set(asKey, data);
|
|
155
|
+
|
|
156
|
+
// Write to global store if into attribute is present
|
|
157
|
+
if (intoStore) {
|
|
158
|
+
if (!_stores[intoStore]) _stores[intoStore] = createContext({});
|
|
159
|
+
_stores[intoStore].$set(asKey, data);
|
|
160
|
+
_notifyStoreWatchers();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Success template
|
|
164
|
+
if (successTpl) {
|
|
165
|
+
const clone = _cloneTemplate(successTpl);
|
|
166
|
+
if (clone) {
|
|
167
|
+
el.innerHTML = "";
|
|
168
|
+
// Inject var
|
|
169
|
+
const tplEl = document.getElementById(
|
|
170
|
+
successTpl.replace("#", ""),
|
|
171
|
+
);
|
|
172
|
+
const vn = tplEl?.getAttribute("var") || varName || "result";
|
|
173
|
+
const childCtx = createContext({ [vn]: data }, ctx);
|
|
174
|
+
const wrapper = document.createElement("div");
|
|
175
|
+
wrapper.style.display = "contents";
|
|
176
|
+
wrapper.__ctx = childCtx;
|
|
177
|
+
wrapper.appendChild(clone);
|
|
178
|
+
el.appendChild(wrapper);
|
|
179
|
+
processTree(wrapper);
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// Restore original children and re-process
|
|
183
|
+
el.innerHTML = "";
|
|
184
|
+
for (const child of originalChildren)
|
|
185
|
+
el.appendChild(child.cloneNode(true));
|
|
186
|
+
_clearDeclared(el);
|
|
187
|
+
processTree(el);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Then expression
|
|
191
|
+
if (thenExpr) _execStatement(thenExpr, ctx, { result: data });
|
|
192
|
+
|
|
193
|
+
// Redirect
|
|
194
|
+
if (redirectPath && _routerInstance)
|
|
195
|
+
_routerInstance.push(redirectPath);
|
|
196
|
+
|
|
197
|
+
_emitEvent("fetch:success", { url: resolvedUrl, data });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// SwitchMap: silently ignore aborted requests
|
|
200
|
+
if (err.name === "AbortError") return;
|
|
201
|
+
|
|
202
|
+
_warn(
|
|
203
|
+
`${method.toUpperCase()} ${resolvedUrl} failed:`,
|
|
204
|
+
err.message,
|
|
205
|
+
);
|
|
206
|
+
_emitEvent("fetch:error", { url: resolvedUrl, error: err });
|
|
207
|
+
_emitEvent("error", { url: resolvedUrl, error: err });
|
|
208
|
+
|
|
209
|
+
if (errorTpl) {
|
|
210
|
+
const clone = _cloneTemplate(errorTpl);
|
|
211
|
+
if (clone) {
|
|
212
|
+
el.innerHTML = "";
|
|
213
|
+
const tplEl = document.getElementById(
|
|
214
|
+
errorTpl.replace("#", ""),
|
|
215
|
+
);
|
|
216
|
+
const vn = tplEl?.getAttribute("var") || "err";
|
|
217
|
+
const childCtx = createContext(
|
|
218
|
+
{
|
|
219
|
+
[vn]: {
|
|
220
|
+
message: err.message,
|
|
221
|
+
status: err.status,
|
|
222
|
+
body: err.body,
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
ctx,
|
|
226
|
+
);
|
|
227
|
+
const wrapper = document.createElement("div");
|
|
228
|
+
wrapper.style.display = "contents";
|
|
229
|
+
wrapper.__ctx = childCtx;
|
|
230
|
+
wrapper.appendChild(clone);
|
|
231
|
+
el.appendChild(wrapper);
|
|
232
|
+
processTree(wrapper);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// For forms, intercept submit
|
|
239
|
+
if (el.tagName === "FORM" && method !== "get") {
|
|
240
|
+
el.addEventListener("submit", (e) => {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
doRequest();
|
|
243
|
+
});
|
|
244
|
+
} else if (method === "get") {
|
|
245
|
+
doRequest();
|
|
246
|
+
} else {
|
|
247
|
+
// Non-GET on non-FORM: attach click listener
|
|
248
|
+
el.addEventListener("click", (e) => {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
doRequest();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Reactive URL watching: re-fetch when {expressions} in URL change
|
|
255
|
+
const hasInterpolation = /\{[^}]+\}/.test(url);
|
|
256
|
+
if (hasInterpolation) {
|
|
257
|
+
const debounceMs = parseInt(el.getAttribute("debounce")) || 0;
|
|
258
|
+
let _lastResolvedUrl = _interpolate(url, ctx);
|
|
259
|
+
let _debounceTimer = null;
|
|
260
|
+
|
|
261
|
+
function onAncestorChange() {
|
|
262
|
+
const newUrl = _interpolate(url, ctx);
|
|
263
|
+
if (newUrl !== _lastResolvedUrl) {
|
|
264
|
+
_lastResolvedUrl = newUrl;
|
|
265
|
+
if (_debounceTimer) clearTimeout(_debounceTimer);
|
|
266
|
+
if (debounceMs > 0) {
|
|
267
|
+
_debounceTimer = setTimeout(doRequest, debounceMs);
|
|
268
|
+
} else {
|
|
269
|
+
doRequest();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Watch all ancestor contexts for changes
|
|
275
|
+
let ancestor = parentCtx;
|
|
276
|
+
while (ancestor && ancestor.__isProxy) {
|
|
277
|
+
ancestor.$watch(onAncestorChange);
|
|
278
|
+
ancestor = ancestor.$parent;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Polling
|
|
283
|
+
if (refreshInterval > 0) {
|
|
284
|
+
setInterval(doRequest, refreshInterval);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
// DIRECTIVE: t (i18n translations)
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { _i18n } from "../i18n.js";
|
|
6
|
+
import { evaluate } from "../evaluate.js";
|
|
7
|
+
import { findContext } from "../dom.js";
|
|
8
|
+
import { registerDirective } from "../registry.js";
|
|
9
|
+
|
|
10
|
+
registerDirective("t", {
|
|
11
|
+
priority: 20,
|
|
12
|
+
init(el, name, key) {
|
|
13
|
+
const ctx = findContext(el);
|
|
14
|
+
|
|
15
|
+
function update() {
|
|
16
|
+
const params = {};
|
|
17
|
+
for (const attr of [...el.attributes]) {
|
|
18
|
+
if (attr.name.startsWith("t-") && attr.name !== "t") {
|
|
19
|
+
const paramName = attr.name.replace("t-", "");
|
|
20
|
+
params[paramName] = evaluate(attr.value, ctx) ?? attr.value;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
el.textContent = _i18n.t(key, params);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ctx.$watch(update);
|
|
27
|
+
update();
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
// DIRECTIVES: each, foreach
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { createContext } from "../context.js";
|
|
6
|
+
import { evaluate, resolve } from "../evaluate.js";
|
|
7
|
+
import { findContext, _cloneTemplate } from "../dom.js";
|
|
8
|
+
import { registerDirective, processTree } from "../registry.js";
|
|
9
|
+
import { _animateOut } from "../animations.js";
|
|
10
|
+
|
|
11
|
+
registerDirective("each", {
|
|
12
|
+
priority: 10,
|
|
13
|
+
init(el, name, expr) {
|
|
14
|
+
const ctx = findContext(el);
|
|
15
|
+
const match = expr.match(/^(\w+)\s+in\s+(\S+)$/);
|
|
16
|
+
if (!match) return;
|
|
17
|
+
const [, itemName, listPath] = match;
|
|
18
|
+
const tplId = el.getAttribute("template");
|
|
19
|
+
const elseTpl = el.getAttribute("else");
|
|
20
|
+
const keyExpr = el.getAttribute("key");
|
|
21
|
+
const animEnter =
|
|
22
|
+
el.getAttribute("animate-enter") || el.getAttribute("animate");
|
|
23
|
+
const animLeave = el.getAttribute("animate-leave");
|
|
24
|
+
const stagger = parseInt(el.getAttribute("animate-stagger")) || 0;
|
|
25
|
+
const animDuration = parseInt(el.getAttribute("animate-duration")) || 0;
|
|
26
|
+
|
|
27
|
+
function update() {
|
|
28
|
+
let list = resolve(listPath, ctx);
|
|
29
|
+
if (!Array.isArray(list)) return;
|
|
30
|
+
|
|
31
|
+
// Empty state
|
|
32
|
+
if (list.length === 0 && elseTpl) {
|
|
33
|
+
const clone = _cloneTemplate(elseTpl);
|
|
34
|
+
if (clone) {
|
|
35
|
+
el.innerHTML = "";
|
|
36
|
+
el.appendChild(clone);
|
|
37
|
+
processTree(el);
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tpl = tplId ? document.getElementById(tplId) : null;
|
|
43
|
+
if (!tpl) return;
|
|
44
|
+
|
|
45
|
+
// Animate out old items if animate-leave is set
|
|
46
|
+
if (animLeave && el.children.length > 0) {
|
|
47
|
+
const oldItems = [...el.children];
|
|
48
|
+
let remaining = oldItems.length;
|
|
49
|
+
oldItems.forEach((child) => {
|
|
50
|
+
const target = child.firstElementChild || child;
|
|
51
|
+
target.classList.add(animLeave);
|
|
52
|
+
const done = () => {
|
|
53
|
+
target.classList.remove(animLeave);
|
|
54
|
+
remaining--;
|
|
55
|
+
if (remaining <= 0) renderItems(tpl, list);
|
|
56
|
+
};
|
|
57
|
+
target.addEventListener("animationend", done, { once: true });
|
|
58
|
+
setTimeout(done, animDuration || 2000);
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
renderItems(tpl, list);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderItems(tpl, list) {
|
|
66
|
+
const count = list.length;
|
|
67
|
+
el.innerHTML = "";
|
|
68
|
+
|
|
69
|
+
list.forEach((item, i) => {
|
|
70
|
+
const childData = {
|
|
71
|
+
[itemName]: item,
|
|
72
|
+
$index: i,
|
|
73
|
+
$count: count,
|
|
74
|
+
$first: i === 0,
|
|
75
|
+
$last: i === count - 1,
|
|
76
|
+
$even: i % 2 === 0,
|
|
77
|
+
$odd: i % 2 !== 0,
|
|
78
|
+
};
|
|
79
|
+
const childCtx = createContext(childData, ctx);
|
|
80
|
+
|
|
81
|
+
const clone = tpl.content.cloneNode(true);
|
|
82
|
+
const wrapper = document.createElement("div");
|
|
83
|
+
wrapper.style.display = "contents";
|
|
84
|
+
wrapper.__ctx = childCtx;
|
|
85
|
+
wrapper.appendChild(clone);
|
|
86
|
+
el.appendChild(wrapper);
|
|
87
|
+
processTree(wrapper);
|
|
88
|
+
|
|
89
|
+
// Stagger animation
|
|
90
|
+
if (animEnter && stagger) {
|
|
91
|
+
wrapper.style.animationDelay = i * stagger + "ms";
|
|
92
|
+
}
|
|
93
|
+
if (animEnter) {
|
|
94
|
+
const firstChild = wrapper.firstElementChild;
|
|
95
|
+
if (firstChild) firstChild.classList.add(animEnter);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ctx.$watch(update);
|
|
101
|
+
update();
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
registerDirective("foreach", {
|
|
106
|
+
priority: 10,
|
|
107
|
+
init(el, name, itemName) {
|
|
108
|
+
const ctx = findContext(el);
|
|
109
|
+
const fromPath = el.getAttribute("from");
|
|
110
|
+
const indexName = el.getAttribute("index") || "$index";
|
|
111
|
+
const elseTpl = el.getAttribute("else");
|
|
112
|
+
const filterExpr = el.getAttribute("filter");
|
|
113
|
+
const sortProp = el.getAttribute("sort");
|
|
114
|
+
const limit = parseInt(el.getAttribute("limit")) || Infinity;
|
|
115
|
+
const offset = parseInt(el.getAttribute("offset")) || 0;
|
|
116
|
+
const tplId = el.getAttribute("template");
|
|
117
|
+
const animEnter = el.getAttribute("animate-enter") || el.getAttribute("animate");
|
|
118
|
+
const animLeave = el.getAttribute("animate-leave");
|
|
119
|
+
const stagger = parseInt(el.getAttribute("animate-stagger")) || 0;
|
|
120
|
+
const animDuration = parseInt(el.getAttribute("animate-duration")) || 0;
|
|
121
|
+
|
|
122
|
+
if (!fromPath || !itemName) return;
|
|
123
|
+
|
|
124
|
+
const templateContent = tplId
|
|
125
|
+
? null // Will use external template
|
|
126
|
+
: el.cloneNode(true); // Use the element itself as template
|
|
127
|
+
|
|
128
|
+
function update() {
|
|
129
|
+
let list = resolve(fromPath, ctx);
|
|
130
|
+
if (!Array.isArray(list)) return;
|
|
131
|
+
|
|
132
|
+
// Filter
|
|
133
|
+
if (filterExpr) {
|
|
134
|
+
list = list.filter((item, i) => {
|
|
135
|
+
const tempCtx = createContext(
|
|
136
|
+
{ [itemName]: item, [indexName]: i },
|
|
137
|
+
ctx,
|
|
138
|
+
);
|
|
139
|
+
return !!evaluate(filterExpr, tempCtx);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Sort
|
|
144
|
+
if (sortProp) {
|
|
145
|
+
const desc = sortProp.startsWith("-");
|
|
146
|
+
const key = desc ? sortProp.slice(1) : sortProp;
|
|
147
|
+
list = [...list].sort((a, b) => {
|
|
148
|
+
const va = resolve(key, a) ?? a?.[key];
|
|
149
|
+
const vb = resolve(key, b) ?? b?.[key];
|
|
150
|
+
const r = va < vb ? -1 : va > vb ? 1 : 0;
|
|
151
|
+
return desc ? -r : r;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Offset and limit
|
|
156
|
+
list = list.slice(offset, offset + limit);
|
|
157
|
+
|
|
158
|
+
// Empty
|
|
159
|
+
if (list.length === 0 && elseTpl) {
|
|
160
|
+
const clone = _cloneTemplate(elseTpl);
|
|
161
|
+
if (clone) {
|
|
162
|
+
el.innerHTML = "";
|
|
163
|
+
el.appendChild(clone);
|
|
164
|
+
processTree(el);
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const tpl = tplId ? document.getElementById(tplId) : null;
|
|
170
|
+
const count = list.length;
|
|
171
|
+
|
|
172
|
+
function renderForeachItems() {
|
|
173
|
+
el.innerHTML = "";
|
|
174
|
+
list.forEach((item, i) => {
|
|
175
|
+
const childData = {
|
|
176
|
+
[itemName]: item,
|
|
177
|
+
[indexName]: i,
|
|
178
|
+
$index: i,
|
|
179
|
+
$count: count,
|
|
180
|
+
$first: i === 0,
|
|
181
|
+
$last: i === count - 1,
|
|
182
|
+
$even: i % 2 === 0,
|
|
183
|
+
$odd: i % 2 !== 0,
|
|
184
|
+
};
|
|
185
|
+
const childCtx = createContext(childData, ctx);
|
|
186
|
+
|
|
187
|
+
let clone;
|
|
188
|
+
if (tpl) {
|
|
189
|
+
clone = tpl.content.cloneNode(true);
|
|
190
|
+
} else {
|
|
191
|
+
clone = templateContent.cloneNode(true);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const wrapper = document.createElement("div");
|
|
195
|
+
wrapper.style.display = "contents";
|
|
196
|
+
wrapper.__ctx = childCtx;
|
|
197
|
+
wrapper.appendChild(clone);
|
|
198
|
+
el.appendChild(wrapper);
|
|
199
|
+
processTree(wrapper);
|
|
200
|
+
|
|
201
|
+
// Stagger animation
|
|
202
|
+
if (animEnter && stagger) {
|
|
203
|
+
wrapper.style.animationDelay = (i * stagger) + "ms";
|
|
204
|
+
}
|
|
205
|
+
if (animEnter) {
|
|
206
|
+
const firstChild = wrapper.firstElementChild;
|
|
207
|
+
if (firstChild) firstChild.classList.add(animEnter);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Animate out old items if animate-leave is set
|
|
213
|
+
if (animLeave && el.children.length > 0) {
|
|
214
|
+
const oldItems = [...el.children];
|
|
215
|
+
let remaining = oldItems.length;
|
|
216
|
+
oldItems.forEach((child) => {
|
|
217
|
+
const target = child.firstElementChild || child;
|
|
218
|
+
target.classList.add(animLeave);
|
|
219
|
+
const done = () => {
|
|
220
|
+
target.classList.remove(animLeave);
|
|
221
|
+
remaining--;
|
|
222
|
+
if (remaining <= 0) renderForeachItems();
|
|
223
|
+
};
|
|
224
|
+
target.addEventListener("animationend", done, { once: true });
|
|
225
|
+
setTimeout(done, animDuration || 2000);
|
|
226
|
+
});
|
|
227
|
+
} else {
|
|
228
|
+
renderForeachItems();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
ctx.$watch(update);
|
|
233
|
+
update();
|
|
234
|
+
},
|
|
235
|
+
});
|