flet-box 1.0.2 → 1.0.4

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.
Files changed (48) hide show
  1. package/bin/ota/loader.js +6 -1
  2. package/bin/utils/templates.js +6 -2
  3. package/package.json +3 -2
  4. package/src/animations/AnimatedBox.js +32 -1
  5. package/src/animations/AnimatedText.js +7 -3
  6. package/src/animations/MatrixRain.js +37 -12
  7. package/src/animations/ParallaxBox.js +16 -3
  8. package/src/animations/animate.js +22 -3
  9. package/src/core/runApp.js +13 -1
  10. package/src/create-index.sh +2 -2
  11. package/src/index.d.ts +193 -79
  12. package/src/index.js +1 -0
  13. package/src/navigations/AppBar.js +17 -4
  14. package/src/navigations/BottomNavigation.js +14 -2
  15. package/src/navigations/DrawerItem.js +16 -3
  16. package/src/navigations/Router.js +125 -6
  17. package/src/navigations/Scaffold.js +13 -3
  18. package/src/navigations/Tabs.js +34 -6
  19. package/src/navigations/index.js +2 -0
  20. package/src/tools/dimensions.js +11 -3
  21. package/src/tools/useState.js +115 -68
  22. package/src/utils/getWidgetProps.js +5 -3
  23. package/src/widget-factory/assignProps.js +7 -10
  24. package/src/widget-factory/processProps.js +7 -2
  25. package/src/widget-factory/reactiveValue.js +147 -0
  26. package/src/widget-factory/warnings.js +15 -0
  27. package/src/widget-factory/widgetFactory.js +27 -0
  28. package/src/widgets/Accordion.js +6 -5
  29. package/src/widgets/Audio.js +30 -8
  30. package/src/widgets/BottomSheet.js +24 -1
  31. package/src/widgets/Chart.js +4 -1
  32. package/src/widgets/Checkbox.js +7 -1
  33. package/src/widgets/CircularChart.js +4 -1
  34. package/src/widgets/CodeViewer.js +26 -8
  35. package/src/widgets/DataTable.js +11 -2
  36. package/src/widgets/Dropdown.js +5 -1
  37. package/src/widgets/Image.js +8 -1
  38. package/src/widgets/Input.js +27 -6
  39. package/src/widgets/Inspector.js +16 -10
  40. package/src/widgets/ListView.js +7 -1
  41. package/src/widgets/Markdown.js +53 -8
  42. package/src/widgets/Modal.js +24 -1
  43. package/src/widgets/QRCode.js +9 -2
  44. package/src/widgets/Radio.js +5 -1
  45. package/src/widgets/Slider.js +3 -0
  46. package/src/widgets/SnackBar.js +20 -2
  47. package/src/widgets/Switch.js +5 -1
  48. package/src/widgets/TreeView.js +3 -1
package/bin/ota/loader.js CHANGED
@@ -331,7 +331,12 @@ async function checkAndUpdate(shippedConfig, activeHash) {
331
331
  (async function main() {
332
332
  let shippedConfig = null;
333
333
  try {
334
- shippedConfig = await fetchJson("./config.json");
334
+ // Anchored to this script, not the document: on a static host with SPA
335
+ // fallback (GitHub Pages) the app can boot from a deep URL, where a
336
+ // document-relative "./config.json" would resolve to the wrong folder.
337
+ shippedConfig = await fetchJson(
338
+ new URL("./config.json", import.meta.url).href,
339
+ );
335
340
  } catch (e) {
336
341
  warn("no shipped config.json:", e.message);
337
342
  }
@@ -607,9 +607,13 @@ echo "✅ Bundle listo: \$TARGET/ Prueba: cd \$TARGET && bash run.sh"
607
607
 
608
608
  export const serviceWorkerJs = () => `// service-worker.js - PWA offline support
609
609
  const CACHE_NAME = 'fletbox-v2';
610
+ // Deployment prefix: a worker's own URL always sits at the app root, so this
611
+ // is '/' at the host root and '/repo-name/' on a GitHub Pages project site.
612
+ // Hard-coded '/' entries would miss every file under a subpath.
613
+ const BASE = new URL('./', self.location).pathname;
610
614
  // The shipped (baked-in) shell, precached so the app opens with no connection.
611
615
  // loader.js owns a SEPARATE cache ('fletbox-ota') for downloaded bundles.
612
- const PRECACHE = ['/', '/index.html', '/loader.js', '/config.json', '/src/app.js'];
616
+ const PRECACHE = [BASE, BASE + 'index.html', BASE + 'loader.js', BASE + 'config.json', BASE + 'src/app.js'];
613
617
 
614
618
  self.addEventListener('install', event => {
615
619
  event.waitUntil(
@@ -690,7 +694,7 @@ self.addEventListener('fetch', event => {
690
694
  const cached = await cache.match(req);
691
695
  if (cached) return cached;
692
696
  if (req.mode === 'navigate') {
693
- const shell = await cache.match('/index.html');
697
+ const shell = await cache.match(BASE + 'index.html');
694
698
  if (shell) return shell;
695
699
  }
696
700
  return Response.error();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flet-box",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Declarative, zero-dependency UI framework in vanilla JS — ship web, PWA, Android and iOS from one codebase",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -19,7 +19,8 @@
19
19
  "dev": "node bin/cli.js run --hot",
20
20
  "build": "node bin/cli.js createBundle",
21
21
  "release": "node bin/cli.js release",
22
- "test": "node scripts/check-widgets.mjs"
22
+ "test": "node scripts/check-widgets.mjs && node scripts/check-api-surface.mjs",
23
+ "test:browser": "node scripts/run-browser-tests.mjs"
23
24
  },
24
25
  "keywords": [
25
26
  "fletbox",
@@ -334,6 +334,24 @@ const interpolateGradient = (gradient1, gradient2, t) => {
334
334
  * @param {string} keyframes - The keyframe body (everything inside `@keyframes name { … }`).
335
335
  * @returns {void}
336
336
  */
337
+ /**
338
+ * Stable hash of a keyframe body, used as the generated animation name so that
339
+ * identical keyframes are injected only once.
340
+ *
341
+ * @param {string} keyframes
342
+ * @returns {string}
343
+ */
344
+ const hashKeyframes = (keyframes) => {
345
+ let h1 = 0x811c9dc5;
346
+ let h2 = 0x01000193;
347
+ for (let i = 0; i < keyframes.length; i++) {
348
+ const c = keyframes.charCodeAt(i);
349
+ h1 = Math.imul(h1 ^ c, 0x01000193);
350
+ h2 = Math.imul(h2 + c, 0x85ebca6b) ^ (h2 >>> 13);
351
+ }
352
+ return (h1 >>> 0).toString(36) + (h2 >>> 0).toString(36);
353
+ };
354
+
337
355
  const injectKeyframes = (name, keyframes) => {
338
356
  if (injectedKeyframes.has(name)) return;
339
357
  const style = document.createElement("style");
@@ -365,6 +383,15 @@ const generateKeyframes = (animations) => {
365
383
  const effect = effectMap[anim.effect] || anim.effect;
366
384
  const { from, to, reverse = true } = anim;
367
385
 
386
+ // A descriptor without `from`/`to` is malformed. Skip it with a warning
387
+ // rather than throwing a TypeError out of the caller's entire render.
388
+ if (from === undefined || to === undefined) {
389
+ console.warn(
390
+ `⚠️ [AnimatedBox] Skipping animation "${anim.effect}": "from" and "to" are required.`,
391
+ );
392
+ return;
393
+ }
394
+
368
395
  let value;
369
396
  let color;
370
397
 
@@ -589,8 +616,12 @@ export const AnimatedBox = ({
589
616
  child.style.background = firstAnim.from;
590
617
  }
591
618
 
592
- const name = `anim-${Date.now()}-${animationCounter++}`;
593
619
  const keyframes = generateKeyframes(animations);
620
+ // The name is derived from the keyframe body so identical animations reuse a
621
+ // single <style>. A per-call unique name (`Date.now()`) made the dedupe check
622
+ // in `injectKeyframes` dead code: every call appended a permanent <style> to
623
+ // document.head, and AnimatedText calls this once per letter.
624
+ const name = `anim-${hashKeyframes(keyframes)}`;
594
625
  injectKeyframes(name, keyframes);
595
626
 
596
627
  const duration = animations[0]?.duration || 500;
@@ -39,9 +39,13 @@ export const AnimatedText = ({
39
39
  if (!child) return null;
40
40
  if (!animations || animations.length === 0) return child;
41
41
 
42
- // Extract text content and props from the original Text widget
43
- const originalText = child._props?.text || child.textContent || "";
44
- const originalProps = { ...(child._props || {}) };
42
+ // Extract text content and props from the original Text widget. The factory
43
+ // stores props in `_originalProps`; `_props` is never assigned by anything, so
44
+ // reading it here silently dropped every style (font, size, colour) and the
45
+ // documented per-letter inheritance never happened.
46
+ const sourceProps = child._originalProps || child._props || {};
47
+ const originalText = sourceProps.text || child.textContent || "";
48
+ const originalProps = { ...sourceProps };
45
49
  // Ensure no background animation styles bleed into individual letter widgets
46
50
  delete originalProps.animations;
47
51
 
@@ -52,6 +52,11 @@ export const MatrixRain = (props = {}) => {
52
52
  let drops = [];
53
53
  let columns = 0;
54
54
  let resizeObserver = null;
55
+ let observedParent = null;
56
+ // The canvas is returned to the caller and appended afterwards, so it is NOT
57
+ // connected when the animation starts. `wasConnected` tracks that first mount
58
+ // so the loop can stop when the canvas is later removed from the document.
59
+ let wasConnected = false;
55
60
 
56
61
  /**
57
62
  * Determines the canvas dimensions based on positioning mode.
@@ -84,14 +89,17 @@ export const MatrixRain = (props = {}) => {
84
89
  */
85
90
  const resizeCanvas = () => {
86
91
  if (!canvas) return;
87
- if (
88
- position === "absolute" &&
89
- !resizeObserver &&
90
- typeof ResizeObserver !== "undefined" &&
91
- canvas.parentElement
92
- ) {
93
- resizeObserver = new ResizeObserver(resizeCanvas);
94
- resizeObserver.observe(canvas.parentElement);
92
+ // The canvas is returned to the caller and appended afterwards, so on the
93
+ // first call there is no parent yet. Create the observer once and (re)attach
94
+ // it whenever the canvas ends up living in a different parent.
95
+ if (position === "absolute" && typeof ResizeObserver !== "undefined") {
96
+ if (!resizeObserver) resizeObserver = new ResizeObserver(resizeCanvas);
97
+ const parent = canvas.parentElement;
98
+ if (parent && observedParent !== parent) {
99
+ if (observedParent) resizeObserver.unobserve(observedParent);
100
+ resizeObserver.observe(parent);
101
+ observedParent = parent;
102
+ }
95
103
  }
96
104
  const { width, height } = getCanvasSize();
97
105
  canvas.width = width;
@@ -154,6 +162,25 @@ export const MatrixRain = (props = {}) => {
154
162
  const draw = () => {
155
163
  if (!ctx || !canvas) return;
156
164
 
165
+ // The canvas is handed back to the caller and appended afterwards, so it is
166
+ // disconnected while the animation starts. Once it HAS been mounted, a
167
+ // disconnect means it was removed from the document — stop there.
168
+ //
169
+ // Nothing else covers that path: `runApp`'s teardownTree and Scaffold's
170
+ // observer call `_cleanup()`, but the lifecycle observer in
171
+ // `src/widget-factory/lifecycle.js` only fires `onUnmount`, and a raw canvas
172
+ // has no such hook. Without this guard the loop would keep re-arming at
173
+ // 60fps painting into a detached canvas, forever.
174
+ if (canvas.isConnected) {
175
+ if (!wasConnected) {
176
+ wasConnected = true;
177
+ resizeCanvas(); // size to the real parent and attach the observer
178
+ }
179
+ } else if (wasConnected) {
180
+ cleanup();
181
+ return;
182
+ }
183
+
157
184
  // Semi-transparent black overlay produces the trailing-fade effect
158
185
  ctx.fillStyle = `rgba(0, 0, 0, ${fadeAmount})`;
159
186
  ctx.fillRect(0, 0, canvas.width, canvas.height);
@@ -188,10 +215,8 @@ export const MatrixRain = (props = {}) => {
188
215
  };
189
216
 
190
217
  window.addEventListener("resize", handleResize);
191
- if (position === "absolute" && typeof ResizeObserver !== "undefined") {
192
- resizeObserver = new ResizeObserver(resizeCanvas);
193
- if (canvas.parentElement) resizeObserver.observe(canvas.parentElement);
194
- }
218
+ // NOTE: the parent ResizeObserver is attached from `resizeCanvas()` (see
219
+ // above), because the canvas has no parent until the caller appends it.
195
220
 
196
221
  /**
197
222
  * Stops the animation loop, removes event listeners, disconnects the
@@ -341,12 +341,16 @@ export const ParallaxBox = (props = {}) => {
341
341
 
342
342
  const originalCleanup = container._cleanup;
343
343
  /**
344
- * Removes all event listeners, cancels pending animation frames, clears
345
- * timers, and calls any previously assigned `_cleanup` function.
344
+ * Removes all event listeners, cancels pending animation frames and clears
345
+ * timers. Idempotent, because it runs from BOTH teardown paths: the lifecycle
346
+ * observer fires `onUnmount`, while runApp/Scaffold call `_cleanup`.
346
347
  *
347
348
  * @returns {void}
348
349
  */
349
- container._cleanup = () => {
350
+ let disposed = false;
351
+ const dispose = () => {
352
+ if (disposed) return;
353
+ disposed = true;
350
354
  if (type === "scroll") {
351
355
  window.removeEventListener("scroll", handleScroll);
352
356
  } else {
@@ -360,6 +364,15 @@ export const ParallaxBox = (props = {}) => {
360
364
  if (initialTimer) clearTimeout(initialTimer);
361
365
  transitionTimers.forEach((timer) => clearTimeout(timer));
362
366
  transitionTimers.clear();
367
+ };
368
+
369
+ // The lifecycle observer fires `onUnmount` on a plain DOM disconnect but never
370
+ // calls `_cleanup`, so without this the window scroll listener and the timers
371
+ // survived every removal that was not a full app rebuild.
372
+ container.onUnmount(dispose);
373
+
374
+ container._cleanup = () => {
375
+ dispose();
363
376
  if (originalCleanup) originalCleanup();
364
377
  };
365
378
 
@@ -114,7 +114,13 @@ const getEasing = (type, t) => {
114
114
  const readAnimatedValue = (widget, property) => {
115
115
  if (property === "scale") return 1;
116
116
  if (widget?.style && property in widget.style) {
117
- return widget.style[property];
117
+ const inline = widget.style[property];
118
+ // An inline-unset property reads as "" and `interpolate` turns that into the
119
+ // string "NaN", which the browser silently drops — so `animate(el, "opacity",
120
+ // undefined, 1, 300)` (the documented API) wrote "NaN" every frame and never
121
+ // animated anything. Fall back to the property's neutral value.
122
+ if (inline !== "" && inline !== null && inline !== undefined) return inline;
123
+ return property === "opacity" ? 1 : 0;
118
124
  }
119
125
  return widget?.[property];
120
126
  };
@@ -180,13 +186,21 @@ export const animate = (
180
186
  duration = 300,
181
187
  easing = "linear",
182
188
  ) => {
189
+ // A negative duration makes elapsed/duration increasingly negative, so the
190
+ // loop re-armed forever and the property was driven backwards. Clamping the
191
+ // progress is not enough (it just sits at 0 and `0 < 1` stays true), so
192
+ // sanitise the duration itself. `0` collapses to a single frame.
193
+ duration = Math.max(0, Number(duration) || 0);
183
194
  const startTime = performance.now();
184
195
  const startValue = from !== undefined ? from : readAnimatedValue(widget, property);
185
196
  const endValue = to;
186
197
 
187
198
  const step = (currentTime) => {
188
199
  const elapsed = currentTime - startTime;
189
- const progress = Math.min(1, elapsed / duration);
200
+ // Clamp both ends. A negative `duration` makes elapsed/duration increasingly
201
+ // negative, so `progress` never reached 1 and the loop re-armed forever (and
202
+ // `animateAsync` never resolved). `duration: 0` yields Infinity, clamped to 1.
203
+ const progress = Math.max(0, Math.min(1, elapsed / duration));
190
204
  const easedProgress = getEasing(easing, progress);
191
205
  const currentValue = interpolate(startValue, endValue, easedProgress);
192
206
 
@@ -224,6 +238,9 @@ export const animateAsync = (
224
238
  duration = 300,
225
239
  easing = "linear",
226
240
  ) => {
241
+ // See `animate`: a negative duration would keep the loop alive forever and this
242
+ // promise would never resolve.
243
+ duration = Math.max(0, Number(duration) || 0);
227
244
  return new Promise((resolve) => {
228
245
  const startTime = performance.now();
229
246
  const startValue = from !== undefined ? from : readAnimatedValue(widget, property);
@@ -231,7 +248,9 @@ export const animateAsync = (
231
248
 
232
249
  const step = (currentTime) => {
233
250
  const elapsed = currentTime - startTime;
234
- const progress = Math.min(1, elapsed / duration);
251
+ // Same clamp as `animate`: a negative `duration` would otherwise never
252
+ // reach progress 1 and this promise would never resolve.
253
+ const progress = Math.max(0, Math.min(1, elapsed / duration));
235
254
  const easedProgress = getEasing(easing, progress);
236
255
  const currentValue = interpolate(startValue, endValue, easedProgress);
237
256
 
@@ -9,6 +9,7 @@ import {
9
9
  } from "../utils/themes.js";
10
10
  import { Container } from "../widgets/Container.js";
11
11
  import { setGlobalRender } from "../tools/useState.js";
12
+ import { beginRenderPass, endRenderPass } from "../widget-factory/reactiveValue.js";
12
13
 
13
14
  /**
14
15
  * Mounts a FletBox application into the DOM and manages its full lifecycle.
@@ -182,7 +183,18 @@ export const runApp = (
182
183
  currentMainContainer = null;
183
184
  }
184
185
 
185
- const appContent = typeof App === "function" ? App() : App;
186
+ // Wrap the component call in a render pass. A reactive value the component
187
+ // reads *here* — a template literal, a conditional, a list — is a structural
188
+ // read, so its key is flagged for an app render. Reads the factory performs
189
+ // while filling props are not flagged, which is what keeps a directly-passed
190
+ // state value surgical.
191
+ beginRenderPass();
192
+ let appContent;
193
+ try {
194
+ appContent = typeof App === "function" ? App() : App;
195
+ } finally {
196
+ endRenderPass();
197
+ }
186
198
 
187
199
  const mainContainer = Container({
188
200
  margin: 0,
@@ -60,7 +60,7 @@ export {
60
60
  Scaffold, AppBar, Drawer, BottomNavigation, Tabs, DrawerItem,
61
61
  openDrawer, closeDrawer, toggleDrawer, destroyDrawer,
62
62
  initRouter, goTo, goBack, goForward, replace,
63
- getCurrentPath, getCurrentRoute, getCurrentRouteConfig,
63
+ getCurrentPath, getBasePath, getCurrentRoute, getCurrentRouteConfig,
64
64
  getRoute, isActive, subscribe, useParams, useQueryParams,
65
65
  buildUrl, clearRouter
66
66
  } from './src/navigations/index.js';
@@ -178,7 +178,7 @@ export { Drawer, openDrawer, closeDrawer, toggleDrawer, destroyDrawer } from './
178
178
  export { BottomNavigation } from './BottomNavigation.js';
179
179
  export { Tabs } from './Tabs.js';
180
180
  export { DrawerItem } from './DrawerItem.js';
181
- export { initRouter, goTo, goBack, goForward, replace, getCurrentPath, getCurrentRoute, getCurrentRouteConfig, getRoute, isActive, subscribe, useParams, useQueryParams, buildUrl, clearRouter } from './Router.js';
181
+ export { initRouter, goTo, goBack, goForward, replace, getCurrentPath, getBasePath, getCurrentRoute, getCurrentRouteConfig, getRoute, isActive, subscribe, useParams, useQueryParams, buildUrl, clearRouter } from './Router.js';
182
182
  EOF
183
183
  echo "✅ Created: src/navigations/index.js"
184
184