@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erickxavier/no-js",
3
- "version": "1.0.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
- if (val !== undefined && val !== null) el.textContent = String(val);
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);
@@ -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
  });
@@ -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) firstChild.classList.add(animEnter);
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) firstChild.classList.add(animEnter);
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
- const clone = _cloneTemplate(fallbackTpl);
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
- locale: "en",
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
@@ -183,7 +183,7 @@ const NoJS = {
183
183
  resolve,
184
184
 
185
185
  // Version
186
- version: "1.0.0",
186
+ version: "1.0.2",
187
187
  };
188
188
 
189
189
  export default NoJS;
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 path = window.location.hash.slice(1) || "/";
234
- navigate(path, true);
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) || "/";