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/src/index.js CHANGED
@@ -208,6 +208,7 @@ export {
208
208
  goForward,
209
209
  replace,
210
210
  getCurrentPath,
211
+ getBasePath,
211
212
  getCurrentRoute,
212
213
  getCurrentRouteConfig,
213
214
  getRoute,
@@ -165,6 +165,8 @@ export const AppBar = (props) => {
165
165
  display: "flex",
166
166
  alignItems: "center",
167
167
  justifyContent: "space-between",
168
+ width: "100%",
169
+ boxSizing: "border-box",
168
170
  minHeight: 56,
169
171
  borderRadius: finalBorderRadius,
170
172
  marginTop: finalMarginTop,
@@ -217,7 +219,10 @@ export const AppBar = (props) => {
217
219
  }
218
220
 
219
221
  const centerSection = Container({
220
- flex: centerTitle ? 1 : "none",
222
+ // Always flex:1 so the title absorbs whatever the leading and action zones
223
+ // do not use. With flex:"none" a long title keeps its intrinsic width and
224
+ // pushes the actions past the viewport edge on narrow screens.
225
+ flex: 1,
221
226
  display: "flex",
222
227
  alignItems: "center",
223
228
  justifyContent: centerTitle ? "center" : "flex-start",
@@ -232,9 +237,11 @@ export const AppBar = (props) => {
232
237
  // ========== RIGHT SECTION (only rendered when there are valid actions) ==========
233
238
  if (validActions.length > 0) {
234
239
  const rightSection = Row({
240
+ // Row defaults to width:"100%", which inside the flex header demands the
241
+ // whole bar width and overflows it. The zone must be content-sized.
242
+ width: "auto",
235
243
  alignItems: "center",
236
244
  justifyContent: "flex-end",
237
- paddingRight: paddingRight,
238
245
  gap: actionsGap,
239
246
  minWidth: 48,
240
247
  flexShrink: 0,
@@ -270,9 +277,15 @@ export const AppBar = (props) => {
270
277
  lastScrollY = currentScrollY;
271
278
  };
272
279
  window.addEventListener("scroll", handleScroll);
273
- // Store the remover so _cleanup can deregister the listener.
274
- appBar._cleanupScroll = () =>
280
+ // Register the remover through BOTH teardown paths. `_cleanup` is only called
281
+ // by runApp's teardownTree / Scaffold, while a plain DOM disconnect goes
282
+ // through the lifecycle observer, which never calls `_cleanup` — so without
283
+ // `onUnmount` this listener (and the detached bar) stayed alive forever.
284
+ // `removeEventListener` is idempotent, so running both is harmless.
285
+ const removeScroll = () =>
275
286
  window.removeEventListener("scroll", handleScroll);
287
+ appBar._cleanupScroll = removeScroll;
288
+ appBar.onUnmount(removeScroll);
276
289
  }
277
290
 
278
291
  // ========== PUBLIC API ==========
@@ -285,10 +285,22 @@ export const BottomNavigation = (props) => {
285
285
  return getCurrentPath();
286
286
  };
287
287
 
288
- // Cleanup: unsubscribe from the router when the bar is removed from the DOM.
288
+ // Cleanup: release the router subscription on EITHER teardown path. `_cleanup`
289
+ // is only run by runApp's teardownTree / Scaffold; the lifecycle observer that
290
+ // fires on a plain DOM disconnect never calls it, so without `onUnmount` the
291
+ // router kept invoking `updateColorsFromRouter()` (and `onTabChange`) on a
292
+ // detached bar forever. The disposer is idempotent because both paths can run.
293
+ let unsubscribed = false;
294
+ const dispose = () => {
295
+ if (unsubscribed) return;
296
+ unsubscribed = true;
297
+ if (unsubscribe) unsubscribe();
298
+ };
299
+ container.onUnmount(dispose);
300
+
289
301
  const originalCleanup = container._cleanup;
290
302
  container._cleanup = () => {
291
- if (unsubscribe) unsubscribe();
303
+ dispose();
292
304
  if (originalCleanup) originalCleanup();
293
305
  };
294
306
 
@@ -246,11 +246,24 @@ export const DrawerItem = (props) => {
246
246
  // Apply the initial visual state now that DOM refs are set.
247
247
  updateUI();
248
248
 
249
- // Cleanup: unsubscribe from router and selection events to prevent memory leaks.
250
- const originalCleanup = container._cleanup;
251
- container._cleanup = () => {
249
+ // Cleanup: release the router subscription and the selection listener on EITHER
250
+ // teardown path. `_cleanup` is only run by runApp's teardownTree / Scaffold; the
251
+ // lifecycle observer that fires on a plain DOM disconnect never calls it, so
252
+ // without `onUnmount` every removed item kept a live router callback (retaining
253
+ // its DOM refs) plus a listener on the shared selection EventTarget. The
254
+ // disposer is idempotent because both paths can run.
255
+ let disposed = false;
256
+ const dispose = () => {
257
+ if (disposed) return;
258
+ disposed = true;
252
259
  if (unsubscribeRouter) unsubscribeRouter();
253
260
  if (unsubscribeGlobal) unsubscribeGlobal();
261
+ };
262
+ container.onUnmount(dispose);
263
+
264
+ const originalCleanup = container._cleanup;
265
+ container._cleanup = () => {
266
+ dispose();
254
267
  if (originalCleanup) originalCleanup();
255
268
  };
256
269
 
@@ -7,6 +7,7 @@
7
7
  * - Dynamic segments: `/user/:id`, `/post/:slug/comments`
8
8
  * - Wildcard / 404 routes: `*` or `/404`
9
9
  * - Query string parameters
10
+ * - Deployment base paths (`/repo-name/`) for static hosts like GitHub Pages
10
11
  * - Browser history integration (pushState / popstate)
11
12
  * - Observer pattern (subscribe/notify) so layout shells like Scaffold can
12
13
  * re-render when the route changes.
@@ -42,6 +43,15 @@ let currentQuery = {};
42
43
  /** @type {Function|null} Removes the popstate event listener; set during initRouter. */
43
44
  let removePopStateListener = null;
44
45
 
46
+ /**
47
+ * Deployment base path the app is served under, e.g. "/flet-box-docs".
48
+ * Empty string means the app owns the host root. Router state stays
49
+ * app-relative; the base is only added when writing browser URLs.
50
+ *
51
+ * @type {string}
52
+ */
53
+ let basePath = "";
54
+
45
55
  // ========== UTILITIES ==========
46
56
 
47
57
  /**
@@ -132,6 +142,93 @@ const findMatchingRoute = (path) => {
132
142
  return null;
133
143
  };
134
144
 
145
+ // ========== DEPLOYMENT BASE PATH ==========
146
+
147
+ /**
148
+ * Normalize a deployment base to "" or "/segment(s)" with no trailing slash.
149
+ *
150
+ * @param {string|null|undefined} base - e.g. "/", "flet-box-docs", "/flet-box-docs/".
151
+ * @returns {string} "" for a root-hosted app, otherwise a leading-slash prefix.
152
+ */
153
+ const normalizeBase = (base) => {
154
+ if (!base) return "";
155
+ let out = String(base).trim();
156
+ if (!out.startsWith("/")) out = `/${out}`;
157
+ out = out.replace(/\/+$/, "");
158
+ return out === "/" ? "" : out;
159
+ };
160
+
161
+ /**
162
+ * Build a browser URL from an app-relative path by prefixing the base.
163
+ *
164
+ * @param {string} path - App-relative path, possibly with a query string.
165
+ * @returns {string} Path as seen by the browser.
166
+ */
167
+ const withBase = (path) => `${basePath}${path}`;
168
+
169
+ /**
170
+ * Remove the deployment base from a browser URL path.
171
+ *
172
+ * @param {string} path - Path as seen by the browser.
173
+ * @returns {string} App-relative path.
174
+ */
175
+ const stripBase = (path) =>
176
+ basePath && path.startsWith(basePath)
177
+ ? path.slice(basePath.length) || "/"
178
+ : path;
179
+
180
+ /**
181
+ * Work out which prefix of a browser path is the deployment base.
182
+ *
183
+ * Static hosts such as GitHub Pages serve a project site under
184
+ * `/<repo>/`, so `location.pathname` is `/<repo>/<route>` while the app's
185
+ * route map only knows `/<route>`. Scanning prefixes from the shortest one
186
+ * and returning the first whose remainder matches a registered route keeps
187
+ * root-hosted apps untouched (`base` resolves to "").
188
+ *
189
+ * @param {string} rawPath - Browser path, including any base prefix.
190
+ * @returns {string} Normalized base, or "" when no prefix yields a match.
191
+ */
192
+ const detectBase = (rawPath) => {
193
+ const clean = cleanPath(rawPath);
194
+ if (findMatchingRoute(clean)) return "";
195
+
196
+ const segments = clean.split("/").filter(Boolean);
197
+ // i === segments.length covers the bare subpath root ("/repo/"), where the
198
+ // remainder is the app's own "/" route.
199
+ for (let i = 1; i <= segments.length; i++) {
200
+ const candidate = `/${segments.slice(0, i).join("/")}`;
201
+ const rest = segments.slice(i);
202
+ const remainder = rest.length ? `/${rest.join("/")}` : "/";
203
+ if (findMatchingRoute(remainder)) return normalizeBase(candidate);
204
+ }
205
+ return "";
206
+ };
207
+
208
+ /**
209
+ * Resolve the deployment base for the current document.
210
+ *
211
+ * Precedence: explicit `base` option > `window.__FLETBOX_BASE__` (only when
212
+ * the live URL actually sits under it, so the same bundle works when
213
+ * packaged by Capacitor at the origin root) > auto-detection from the URL.
214
+ *
215
+ * @param {string|null|undefined} explicitBase - Base passed to initRouter.
216
+ * @param {string} rawPath - Browser path the app is being entered on.
217
+ * @returns {string} Normalized base.
218
+ */
219
+ const resolveBase = (explicitBase, rawPath) => {
220
+ const explicit = normalizeBase(explicitBase);
221
+ if (explicit) return explicit;
222
+
223
+ if (typeof window !== "undefined") {
224
+ const declared = normalizeBase(window.__FLETBOX_BASE__);
225
+ if (declared && (rawPath === `${declared}/` || rawPath.startsWith(`${declared}/`))) {
226
+ return declared;
227
+ }
228
+ }
229
+ return detectBase(rawPath);
230
+ };
231
+
135
232
  // ========== INITIALIZATION ==========
136
233
 
137
234
  /**
@@ -146,8 +243,9 @@ const findMatchingRoute = (path) => {
146
243
  *
147
244
  * @param {Object.<string, *>} routesConfig - Map of path patterns to components/config.
148
245
  * @param {string|null} [initialPath=null] - Override the starting path; defaults to window.location.pathname.
246
+ * @param {{base?: string}} [options={}] - `base` pins the deployment prefix; auto-detected when omitted.
149
247
  */
150
- export const initRouter = (routesConfig, initialPath = null) => {
248
+ export const initRouter = (routesConfig, initialPath = null, options = {}) => {
151
249
  routes = routesConfig;
152
250
 
153
251
  // Prefer the explicit initialPath; fall back to the live browser URL so
@@ -157,6 +255,11 @@ export const initRouter = (routesConfig, initialPath = null) => {
157
255
  urlPath = window.location.pathname;
158
256
  }
159
257
 
258
+ // Peel the deployment prefix off the browser URL before matching so a site
259
+ // served under /repo-name/ resolves to its routes instead of redirecting.
260
+ basePath = resolveBase(options.base, urlPath || "/");
261
+ if (urlPath) urlPath = stripBase(urlPath);
262
+
160
263
  // Last resort: use the first registered route or the root.
161
264
  if (!urlPath) {
162
265
  urlPath = Object.keys(routes)[0] || "/";
@@ -188,9 +291,13 @@ export const initRouter = (routesConfig, initialPath = null) => {
188
291
  // redirected to the first route above).
189
292
  if (
190
293
  typeof window !== "undefined" &&
191
- window.location.pathname !== currentPath
294
+ window.location.pathname !== withBase(currentPath)
192
295
  ) {
193
- window.history.replaceState({ path: currentPath }, "", currentPath);
296
+ window.history.replaceState(
297
+ { path: currentPath },
298
+ "",
299
+ withBase(currentPath),
300
+ );
194
301
  }
195
302
 
196
303
  // Listen for browser-native back/forward navigation so the router state
@@ -198,7 +305,8 @@ export const initRouter = (routesConfig, initialPath = null) => {
198
305
  if (typeof window !== "undefined") {
199
306
  if (removePopStateListener) removePopStateListener();
200
307
  const handlePopState = (event) => {
201
- const path = event.state?.path || window.location.pathname;
308
+ const path =
309
+ event.state?.path || stripBase(window.location.pathname);
202
310
  if (path !== currentPath) {
203
311
  currentPath = path;
204
312
  const match = findMatchingRoute(currentPath);
@@ -243,7 +351,7 @@ export const goTo = (path, params = {}) => {
243
351
  currentQuery = { ...queryParams, ...params };
244
352
 
245
353
  if (typeof window !== "undefined") {
246
- window.history.pushState({ path: currentPath }, "", path);
354
+ window.history.pushState({ path: currentPath }, "", withBase(path));
247
355
  }
248
356
  notify();
249
357
  } else {
@@ -278,7 +386,7 @@ export const replace = (path, params = {}) => {
278
386
  currentQuery = { ...queryParams, ...params };
279
387
 
280
388
  if (typeof window !== "undefined") {
281
- window.history.replaceState({ path: currentPath }, "", path);
389
+ window.history.replaceState({ path: currentPath }, "", withBase(path));
282
390
  }
283
391
  notify();
284
392
  }
@@ -316,6 +424,15 @@ export const goForward = () => {
316
424
  */
317
425
  export const getCurrentPath = () => currentPath;
318
426
 
427
+ /**
428
+ * Returns the deployment base the router resolved at init, e.g. "/flet-box-docs".
429
+ * Empty string for a root-hosted app. Use it to build asset or document URLs
430
+ * that must survive being served from a subpath.
431
+ *
432
+ * @returns {string}
433
+ */
434
+ export const getBasePath = () => basePath;
435
+
319
436
  /**
320
437
  * Returns a shallow copy of the current dynamic route params.
321
438
  * Mutations to the returned object do not affect the router state.
@@ -454,6 +571,7 @@ export const clearRouter = () => {
454
571
  listeners = [];
455
572
  currentParams = {};
456
573
  currentQuery = {};
574
+ basePath = "";
457
575
  };
458
576
 
459
577
  export default {
@@ -463,6 +581,7 @@ export default {
463
581
  goBack,
464
582
  goForward,
465
583
  getCurrentPath,
584
+ getBasePath,
466
585
  getCurrentRoute,
467
586
  getCurrentRouteConfig,
468
587
  getRoute,
@@ -18,6 +18,7 @@ import { initRouter, getCurrentRoute, subscribe } from "./Router.js";
18
18
  * @property {number} [navSideBarWidth=260] - Legacy alias for leftNavBarWidth (deprecated).
19
19
  * @property {"left"|"right"} [navSideBarPosition="left"] - Legacy: which side navSideBar renders on.
20
20
  * @property {Object} [routes] - Route map object for router mode (path → component).
21
+ * @property {string} [basePath] - Deployment prefix, e.g. "/flet-box-docs". Auto-detected when omitted.
21
22
  * @property {string} [backgroundColor] - Background color of the scaffold shell.
22
23
  * @property {boolean} [closeDrawerOnNavigate=false] - Whether to close the drawer automatically on route change.
23
24
  */
@@ -54,6 +55,7 @@ export const Scaffold = (props) => {
54
55
  navSideBarWidth = 260,
55
56
  navSideBarPosition = "left",
56
57
  routes,
58
+ basePath,
57
59
  backgroundColor = colors.background,
58
60
  closeDrawerOnNavigate = false,
59
61
  ...rest
@@ -81,11 +83,12 @@ export const Scaffold = (props) => {
81
83
  }
82
84
 
83
85
  // Bootstrap the router with the current browser URL so deep-links work
84
- // on first load without redirecting to "/".
86
+ // on first load without redirecting to "/". `basePath` pins the deployment
87
+ // prefix when the app is served from a subpath (e.g. GitHub Pages).
85
88
  if (isRouterMode && routesConfig) {
86
89
  const currentUrl =
87
90
  typeof window !== "undefined" ? window.location.pathname : "/";
88
- initRouter(routesConfig, currentUrl);
91
+ initRouter(routesConfig, currentUrl, { base: basePath });
89
92
  }
90
93
 
91
94
  // Resolve the legacy navSideBar prop to the appropriate side.
@@ -491,8 +494,15 @@ export const Scaffold = (props) => {
491
494
  clearContainer(fabContainer);
492
495
  clearContainer(leftNavBarContainer);
493
496
  clearContainer(rightNavBarContainer);
494
- if (container._drawer?.parentNode)
497
+ // `destroy()` releases the viewport-resize listener, the Escape key handler
498
+ // and the pending close timer, then removes the node itself. Removing only
499
+ // the node (the previous behaviour) left all three behind, so every rebuilt
500
+ // scaffold leaked one more listener and one more body child.
501
+ if (typeof container._drawer?.destroy === "function") {
502
+ container._drawer.destroy();
503
+ } else if (container._drawer?.parentNode) {
495
504
  container._drawer.parentNode.removeChild(container._drawer);
505
+ }
496
506
  if (originalCleanup) originalCleanup();
497
507
  };
498
508
 
@@ -318,6 +318,11 @@ export const Tabs = (props) => {
318
318
  updateContent();
319
319
  };
320
320
 
321
+ // Mount the initially active pane during construction. Without this the pane
322
+ // area stayed empty until the user clicked a *different* tab, even though the
323
+ // first tab already rendered as `aria-selected="true"`.
324
+ updateContent();
325
+
321
326
  // Defer the initial slider position calculation by one frame so the DOM
322
327
  // has been laid out and getBoundingClientRect() returns real values.
323
328
  let initialPositionTimer = setTimeout(() => {
@@ -341,18 +346,41 @@ export const Tabs = (props) => {
341
346
 
342
347
  // Also observe the tab bar element itself so the thumb repositions when
343
348
  // the container resizes independently of the window (e.g. sidebar toggle).
344
- if (typeof ResizeObserver !== "undefined") {
349
+ // Only the slider variant has a thumb, so skip it for the other variants.
350
+ if (variant === "slider" && typeof ResizeObserver !== "undefined") {
345
351
  resizeObserver = new ResizeObserver(() => updateSliderPosition());
346
352
  if (tabsWrapperRef) resizeObserver.observe(tabsWrapperRef);
347
353
  }
348
354
 
349
- // Cleanup: cancel timers and remove listeners when the component is unmounted.
355
+ // Cleanup: release the timers, the window listener and the observer on EITHER
356
+ // teardown path. `_cleanup` is only run by runApp's teardownTree / Scaffold; a
357
+ // plain DOM disconnect goes through the lifecycle observer, which never calls
358
+ // it — so without `onUnmount` the resize listener stayed registered and the
359
+ // ResizeObserver kept observing (and retaining) a detached tab bar. The
360
+ // disposer is idempotent because both paths can run.
361
+ let disposed = false;
362
+ const dispose = () => {
363
+ if (disposed) return;
364
+ disposed = true;
365
+ if (initialPositionTimer) {
366
+ clearTimeout(initialPositionTimer);
367
+ initialPositionTimer = null;
368
+ }
369
+ if (resizeTimeout) {
370
+ clearTimeout(resizeTimeout);
371
+ resizeTimeout = null;
372
+ }
373
+ window.removeEventListener("resize", handleResize);
374
+ if (resizeObserver) {
375
+ resizeObserver.disconnect();
376
+ resizeObserver = null;
377
+ }
378
+ };
379
+ container.onUnmount(dispose);
380
+
350
381
  const originalCleanup = container._cleanup;
351
382
  container._cleanup = () => {
352
- if (initialPositionTimer) clearTimeout(initialPositionTimer);
353
- if (resizeTimeout) clearTimeout(resizeTimeout);
354
- window.removeEventListener("resize", handleResize);
355
- if (resizeObserver) resizeObserver.disconnect();
383
+ dispose();
356
384
  if (originalCleanup) originalCleanup();
357
385
  };
358
386
 
@@ -36,6 +36,8 @@ export {
36
36
  replace,
37
37
  /** Returns the currently active path. */
38
38
  getCurrentPath,
39
+ /** Returns the deployment base path, e.g. "/flet-box-docs". */
40
+ getBasePath,
39
41
  /** Returns the component/config for the current route. */
40
42
  getCurrentRoute,
41
43
  /** Returns the full route state (route, params, query, path). */
@@ -16,8 +16,14 @@
16
16
  * // Call remove() to stop listening.
17
17
  */
18
18
 
19
- let windowWidth = window.innerWidth;
20
- let windowHeight = window.innerHeight;
19
+ // SSR / Node guard: this module must stay importable where `window` does not
20
+ // exist (SSR, test runners, build tooling). Reading `window.innerWidth` at
21
+ // module scope used to throw a ReferenceError on import. Same convention as
22
+ // `src/core/pwa.js`.
23
+ const hasWindow = typeof window !== "undefined";
24
+
25
+ let windowWidth = hasWindow ? window.innerWidth : 0;
26
+ let windowHeight = hasWindow ? window.innerHeight : 0;
21
27
 
22
28
  // Live named exports — is-module live bindings: importers of { width, height }
23
29
  // always read the current value, kept in sync on resize.
@@ -45,7 +51,9 @@ const handleResize = () => {
45
51
  });
46
52
  };
47
53
 
48
- window.addEventListener("resize", handleResize);
54
+ // Only subscribe inside a browser; in Node the listener list stays inert and
55
+ // `addListener` / `removeListener` keep working without throwing.
56
+ if (hasWindow) window.addEventListener("resize", handleResize);
49
57
 
50
58
  /**
51
59
  * Dimensions object — provides getter properties and listener management.