@erickxavier/no-js 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -9
- package/dist/cjs/no.js +4 -4
- package/dist/cjs/no.js.map +3 -3
- package/dist/esm/no.js +4 -4
- package/dist/esm/no.js.map +3 -3
- package/dist/iife/no.js +4 -4
- package/dist/iife/no.js.map +3 -3
- package/package.json +9 -2
- package/src/directives/binding.js +1 -1
- package/src/directives/conditionals.js +5 -0
- package/src/directives/i18n.js +2 -1
- package/src/directives/loops.js +14 -10
- package/src/directives/validation.js +30 -14
- package/src/evaluate.js +13 -1
- package/src/i18n.js +17 -1
- package/src/index.js +1 -1
- package/src/router.js +49 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erickxavier/no-js",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "The HTML-first reactive framework — build dynamic web apps with just HTML attributes, no JavaScript required",
|
|
5
5
|
"main": "dist/cjs/no.js",
|
|
6
6
|
"module": "dist/esm/no.js",
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
"test:watch": "jest --watch",
|
|
28
28
|
"test:coverage": "jest --coverage",
|
|
29
29
|
"test:verbose": "jest --verbose",
|
|
30
|
+
"test:e2e": "npx playwright test --config e2e/playwright.config.ts",
|
|
31
|
+
"test:e2e:ui": "npx playwright test --config e2e/playwright.config.ts --ui",
|
|
32
|
+
"test:e2e:headed": "npx playwright test --config e2e/playwright.config.ts --headed",
|
|
33
|
+
"test:e2e:report": "npx playwright show-report e2e/playwright-report",
|
|
34
|
+
"test:all": "npm test && npm run test:e2e",
|
|
30
35
|
"prepublishOnly": "npm run build"
|
|
31
36
|
},
|
|
32
37
|
"keywords": [
|
|
@@ -54,11 +59,13 @@
|
|
|
54
59
|
"devDependencies": {
|
|
55
60
|
"@babel/core": "^7.29.0",
|
|
56
61
|
"@babel/preset-env": "^7.29.0",
|
|
62
|
+
"@playwright/test": "^1.58.2",
|
|
57
63
|
"@testing-library/jest-dom": "^6.9.1",
|
|
58
64
|
"babel-jest": "^30.2.0",
|
|
59
65
|
"esbuild": "^0.27.3",
|
|
60
66
|
"jest": "^30.2.0",
|
|
61
67
|
"jest-environment-jsdom": "^30.2.0",
|
|
62
|
-
"jsdom": "^28.1.0"
|
|
68
|
+
"jsdom": "^28.1.0",
|
|
69
|
+
"serve": "^14.2.5"
|
|
63
70
|
}
|
|
64
71
|
}
|
|
@@ -13,7 +13,7 @@ registerDirective("bind", {
|
|
|
13
13
|
const ctx = findContext(el);
|
|
14
14
|
function update() {
|
|
15
15
|
const val = evaluate(expr, ctx);
|
|
16
|
-
|
|
16
|
+
el.textContent = (val !== undefined && val !== null) ? String(val) : '';
|
|
17
17
|
}
|
|
18
18
|
_watchExpr(expr, ctx, update);
|
|
19
19
|
update();
|
|
@@ -82,6 +82,7 @@ registerDirective("else-if", {
|
|
|
82
82
|
// Works like `if` but checks previous sibling's condition
|
|
83
83
|
const ctx = findContext(el);
|
|
84
84
|
const thenId = el.getAttribute("then");
|
|
85
|
+
const originalChildren = [...el.childNodes].map((n) => n.cloneNode(true));
|
|
85
86
|
|
|
86
87
|
function update() {
|
|
87
88
|
// Check if any preceding if/else-if was true
|
|
@@ -108,6 +109,10 @@ registerDirective("else-if", {
|
|
|
108
109
|
el.innerHTML = "";
|
|
109
110
|
el.appendChild(clone);
|
|
110
111
|
}
|
|
112
|
+
} else {
|
|
113
|
+
el.innerHTML = "";
|
|
114
|
+
for (const child of originalChildren)
|
|
115
|
+
el.appendChild(child.cloneNode(true));
|
|
111
116
|
}
|
|
112
117
|
_clearDeclared(el);
|
|
113
118
|
processTree(el);
|
package/src/directives/i18n.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// DIRECTIVE: t (i18n translations)
|
|
3
3
|
// ═══════════════════════════════════════════════════════════════════════
|
|
4
4
|
|
|
5
|
-
import { _i18n } from "../i18n.js";
|
|
5
|
+
import { _i18n, _watchI18n } from "../i18n.js";
|
|
6
6
|
import { evaluate } from "../evaluate.js";
|
|
7
7
|
import { findContext } from "../dom.js";
|
|
8
8
|
import { registerDirective } from "../registry.js";
|
|
@@ -24,6 +24,7 @@ registerDirective("t", {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
ctx.$watch(update);
|
|
27
|
+
_watchI18n(update);
|
|
27
28
|
update();
|
|
28
29
|
},
|
|
29
30
|
});
|
package/src/directives/loops.js
CHANGED
|
@@ -86,13 +86,15 @@ registerDirective("each", {
|
|
|
86
86
|
el.appendChild(wrapper);
|
|
87
87
|
processTree(wrapper);
|
|
88
88
|
|
|
89
|
-
// Stagger animation
|
|
90
|
-
if (animEnter && stagger) {
|
|
91
|
-
wrapper.style.animationDelay = i * stagger + "ms";
|
|
92
|
-
}
|
|
93
89
|
if (animEnter) {
|
|
94
90
|
const firstChild = wrapper.firstElementChild;
|
|
95
|
-
if (firstChild)
|
|
91
|
+
if (firstChild) {
|
|
92
|
+
firstChild.classList.add(animEnter);
|
|
93
|
+
// Stagger animation — delay must be on the child, not the wrapper
|
|
94
|
+
if (stagger) {
|
|
95
|
+
firstChild.style.animationDelay = i * stagger + "ms";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
96
98
|
}
|
|
97
99
|
});
|
|
98
100
|
}
|
|
@@ -198,13 +200,15 @@ registerDirective("foreach", {
|
|
|
198
200
|
el.appendChild(wrapper);
|
|
199
201
|
processTree(wrapper);
|
|
200
202
|
|
|
201
|
-
// Stagger animation
|
|
202
|
-
if (animEnter && stagger) {
|
|
203
|
-
wrapper.style.animationDelay = (i * stagger) + "ms";
|
|
204
|
-
}
|
|
205
203
|
if (animEnter) {
|
|
206
204
|
const firstChild = wrapper.firstElementChild;
|
|
207
|
-
if (firstChild)
|
|
205
|
+
if (firstChild) {
|
|
206
|
+
firstChild.classList.add(animEnter);
|
|
207
|
+
// Stagger animation — delay must be on the child, not the wrapper
|
|
208
|
+
if (stagger) {
|
|
209
|
+
firstChild.style.animationDelay = (i * stagger) + "ms";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
208
212
|
}
|
|
209
213
|
});
|
|
210
214
|
}
|
|
@@ -19,6 +19,10 @@ function _validateField(value, rules, allValues) {
|
|
|
19
19
|
} else {
|
|
20
20
|
// Built-in validators
|
|
21
21
|
switch (name) {
|
|
22
|
+
case "required":
|
|
23
|
+
if (value == null || String(value).trim() === "")
|
|
24
|
+
return "Required";
|
|
25
|
+
break;
|
|
22
26
|
case "email":
|
|
23
27
|
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
24
28
|
return "Invalid email";
|
|
@@ -90,6 +94,8 @@ registerDirective("validate", {
|
|
|
90
94
|
errors: {},
|
|
91
95
|
values: {},
|
|
92
96
|
reset: () => {
|
|
97
|
+
formCtx.dirty = false;
|
|
98
|
+
formCtx.touched = false;
|
|
93
99
|
el.reset();
|
|
94
100
|
checkValidity();
|
|
95
101
|
},
|
|
@@ -194,22 +200,32 @@ registerDirective("error-boundary", {
|
|
|
194
200
|
init(el, name, fallbackTpl) {
|
|
195
201
|
const ctx = findContext(el);
|
|
196
202
|
|
|
203
|
+
function showFallback(message) {
|
|
204
|
+
const clone = _cloneTemplate(fallbackTpl);
|
|
205
|
+
if (clone) {
|
|
206
|
+
const childCtx = createContext(
|
|
207
|
+
{ err: { message } },
|
|
208
|
+
ctx,
|
|
209
|
+
);
|
|
210
|
+
el.innerHTML = "";
|
|
211
|
+
const wrapper = document.createElement("div");
|
|
212
|
+
wrapper.style.display = "contents";
|
|
213
|
+
wrapper.__ctx = childCtx;
|
|
214
|
+
wrapper.appendChild(clone);
|
|
215
|
+
el.appendChild(wrapper);
|
|
216
|
+
processTree(wrapper);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Listen for NoJS expression errors dispatched from event handlers
|
|
221
|
+
el.addEventListener("nojs:error", (e) => {
|
|
222
|
+
showFallback(e.detail?.message || "An error occurred");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Listen for window-level errors (resource load failures, etc.)
|
|
197
226
|
window.addEventListener("error", (e) => {
|
|
198
227
|
if (el.contains(e.target) || el === e.target) {
|
|
199
|
-
|
|
200
|
-
if (clone) {
|
|
201
|
-
const childCtx = createContext(
|
|
202
|
-
{ err: { message: e.message } },
|
|
203
|
-
ctx,
|
|
204
|
-
);
|
|
205
|
-
el.innerHTML = "";
|
|
206
|
-
const wrapper = document.createElement("div");
|
|
207
|
-
wrapper.style.display = "contents";
|
|
208
|
-
wrapper.__ctx = childCtx;
|
|
209
|
-
wrapper.appendChild(clone);
|
|
210
|
-
el.appendChild(wrapper);
|
|
211
|
-
processTree(wrapper);
|
|
212
|
-
}
|
|
228
|
+
showFallback(e.message || "An error occurred");
|
|
213
229
|
}
|
|
214
230
|
});
|
|
215
231
|
},
|
package/src/evaluate.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// EXPRESSION EVALUATOR
|
|
3
3
|
// ═══════════════════════════════════════════════════════════════════════
|
|
4
4
|
|
|
5
|
-
import { _stores, _routerInstance, _filters, _warn, _config } from "./globals.js";
|
|
5
|
+
import { _stores, _routerInstance, _filters, _warn, _config, _notifyStoreWatchers } from "./globals.js";
|
|
6
6
|
import { _i18n } from "./i18n.js";
|
|
7
7
|
import { _collectKeys } from "./context.js";
|
|
8
8
|
|
|
@@ -272,6 +272,7 @@ export function _execStatement(expr, ctx, extraVars = {}) {
|
|
|
272
272
|
_wCtx = _wCtx.$parent;
|
|
273
273
|
}
|
|
274
274
|
const setters = [...chainKeys]
|
|
275
|
+
.filter((k) => !k.startsWith("$"))
|
|
275
276
|
.map(
|
|
276
277
|
(k) =>
|
|
277
278
|
`{let _c=__ctx;while(_c&&_c.__isProxy){if('${k}'in _c.__raw){_c.$set('${k}',typeof ${k}!=='undefined'?${k}:_c.__raw['${k}']);break;}_c=_c.$parent;}}`,
|
|
@@ -280,8 +281,19 @@ export function _execStatement(expr, ctx, extraVars = {}) {
|
|
|
280
281
|
|
|
281
282
|
const fn = new Function("__ctx", ...keyArr, `${expr};\n${setters}`);
|
|
282
283
|
fn(ctx, ...valArr);
|
|
284
|
+
|
|
285
|
+
// Notify global store watchers when expression touches $store
|
|
286
|
+
if (typeof expr === "string" && expr.includes("$store")) {
|
|
287
|
+
_notifyStoreWatchers();
|
|
288
|
+
}
|
|
283
289
|
} catch (e) {
|
|
284
290
|
_warn("Expression error:", expr, e.message);
|
|
291
|
+
// Dispatch a custom DOM event so error-boundary directives can catch it
|
|
292
|
+
if (extraVars.$el) {
|
|
293
|
+
extraVars.$el.dispatchEvent(
|
|
294
|
+
new CustomEvent("nojs:error", { bubbles: true, detail: { message: e.message, error: e } })
|
|
295
|
+
);
|
|
296
|
+
}
|
|
285
297
|
}
|
|
286
298
|
}
|
|
287
299
|
|
package/src/i18n.js
CHANGED
|
@@ -4,9 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
import { _config } from "./globals.js";
|
|
6
6
|
|
|
7
|
+
const _i18nListeners = new Set();
|
|
8
|
+
|
|
9
|
+
export function _watchI18n(fn) {
|
|
10
|
+
_i18nListeners.add(fn);
|
|
11
|
+
return () => _i18nListeners.delete(fn);
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
export const _i18n = {
|
|
8
|
-
|
|
15
|
+
_locale: "en",
|
|
9
16
|
locales: {},
|
|
17
|
+
get locale() {
|
|
18
|
+
return this._locale;
|
|
19
|
+
},
|
|
20
|
+
set locale(v) {
|
|
21
|
+
if (this._locale !== v) {
|
|
22
|
+
this._locale = v;
|
|
23
|
+
_i18nListeners.forEach((fn) => fn());
|
|
24
|
+
}
|
|
25
|
+
},
|
|
10
26
|
t(key, params = {}) {
|
|
11
27
|
const messages =
|
|
12
28
|
_i18n.locales[_i18n.locale] ||
|
package/src/index.js
CHANGED
package/src/router.js
CHANGED
|
@@ -99,6 +99,15 @@ export function _createRouter() {
|
|
|
99
99
|
// Render
|
|
100
100
|
await _renderRoute(matched);
|
|
101
101
|
listeners.forEach((fn) => fn(current));
|
|
102
|
+
|
|
103
|
+
// Scroll to anchor if hash is present (e.g. route="/docs#cheatsheet")
|
|
104
|
+
if (current.hash) {
|
|
105
|
+
const anchorId = current.hash.slice(1);
|
|
106
|
+
requestAnimationFrame(() => {
|
|
107
|
+
const el = document.getElementById(anchorId);
|
|
108
|
+
if (el) el.scrollIntoView({ behavior: "smooth" });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
async function _renderRoute(matched) {
|
|
@@ -184,6 +193,18 @@ export function _createRouter() {
|
|
|
184
193
|
// "preserve" — do nothing, keep current scroll position
|
|
185
194
|
}
|
|
186
195
|
|
|
196
|
+
function _scrollToAnchor(id, el) {
|
|
197
|
+
el.scrollIntoView({ behavior: "smooth" });
|
|
198
|
+
|
|
199
|
+
// Update active class on anchor links that point to "#<id>"
|
|
200
|
+
const selector = 'a[href="#' + id + '"]';
|
|
201
|
+
document.querySelectorAll('a[href^="#"]').forEach((a) => {
|
|
202
|
+
if (!a.hasAttribute("route")) {
|
|
203
|
+
a.classList.toggle("active", a.matches(selector));
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
187
208
|
const router = {
|
|
188
209
|
get current() {
|
|
189
210
|
return current;
|
|
@@ -224,14 +245,40 @@ export function _createRouter() {
|
|
|
224
245
|
e.preventDefault();
|
|
225
246
|
const path = link.getAttribute("route");
|
|
226
247
|
navigate(path);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// In hash mode, intercept plain anchor links (href="#id") so they
|
|
252
|
+
// scroll to the target element instead of conflicting with the router.
|
|
253
|
+
if (_config.router.mode === "hash") {
|
|
254
|
+
const anchor = e.target.closest('a[href^="#"]');
|
|
255
|
+
if (anchor && !anchor.hasAttribute("route")) {
|
|
256
|
+
const href = anchor.getAttribute("href");
|
|
257
|
+
const id = href.slice(1);
|
|
258
|
+
if (id && !id.startsWith("/")) {
|
|
259
|
+
const target = document.getElementById(id);
|
|
260
|
+
if (target) {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
_scrollToAnchor(id, target);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
227
266
|
}
|
|
228
267
|
});
|
|
229
268
|
|
|
230
269
|
// Listen for URL changes
|
|
231
270
|
if (_config.router.mode === "hash") {
|
|
232
271
|
window.addEventListener("hashchange", () => {
|
|
233
|
-
const
|
|
234
|
-
|
|
272
|
+
const raw = window.location.hash.slice(1) || "/";
|
|
273
|
+
if (!raw.startsWith("/")) {
|
|
274
|
+
const el = document.getElementById(raw);
|
|
275
|
+
if (el) {
|
|
276
|
+
_scrollToAnchor(raw, el);
|
|
277
|
+
window.history.replaceState(null, "", "#" + current.path);
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
navigate(raw, true);
|
|
235
282
|
});
|
|
236
283
|
// Initial route
|
|
237
284
|
const path = window.location.hash.slice(1) || "/";
|