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
@@ -58,6 +58,8 @@ export const BottomSheet = (props) => {
58
58
 
59
59
  let isOpen = false;
60
60
  let overlay = null;
61
+ // Pending "hide after the close animation" timer, so it can be cancelled.
62
+ let closeTimer = null;
61
63
  let sheetContainer = null;
62
64
  let startY = 0;
63
65
  let currentY = 0;
@@ -190,6 +192,18 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
190
192
  overlay.appendChild(sheetContainer);
191
193
  document.body.appendChild(overlay);
192
194
 
195
+ // The overlay lives in `document.body` and can be removed without `close()`
196
+ // ever running (state rebuild, route change, plain removeChild of the host).
197
+ // `_cleanup` is not called on that path, so release the document listener and
198
+ // any pending animation timer through the lifecycle hook too.
199
+ overlay.onUnmount(() => {
200
+ if (closeTimer) {
201
+ clearTimeout(closeTimer);
202
+ closeTimer = null;
203
+ }
204
+ document.removeEventListener("keydown", handleKeyDown);
205
+ });
206
+
193
207
  // Drag handlers
194
208
  const onTouchStart = (e) => {
195
209
  // ✅ Prevent the drag event from propagating to the overlay
@@ -229,6 +243,11 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
229
243
  const open = () => {
230
244
  if (isOpen) return;
231
245
  isOpen = true;
246
+ // Cancel any pending hide from a close that is still animating out.
247
+ if (closeTimer) {
248
+ clearTimeout(closeTimer);
249
+ closeTimer = null;
250
+ }
232
251
  overlay.style.visibility = "visible";
233
252
  overlay.style.opacity = "1";
234
253
  sheetContainer.style.transform = "translateY(0)";
@@ -250,7 +269,11 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
250
269
  isOpen = false;
251
270
  sheetContainer.style.transform = "translateY(100%)";
252
271
  overlay.style.opacity = "0";
253
- setTimeout(() => {
272
+ // Keep the id: `open()` clears it, otherwise reopening within the animation
273
+ // window lets a stale timer hide an OPEN sheet (invisible but isOpen true).
274
+ if (closeTimer) clearTimeout(closeTimer);
275
+ closeTimer = setTimeout(() => {
276
+ closeTimer = null;
254
277
  overlay.style.visibility = "hidden";
255
278
  }, animationDuration);
256
279
  if (onClose) onClose();
@@ -134,7 +134,10 @@ export const Chart = (props) => {
134
134
  const paddingRange = range * 0.1;
135
135
  return {
136
136
  maxValue: maxVal + paddingRange,
137
- minValue: Math.max(0, minVal - paddingRange),
137
+ // Do NOT clamp to 0. With all-negative data that inverts the range
138
+ // (min > max) and every point is drawn far outside the canvas, so the
139
+ // chart looks empty and the axis labels run from 0 to a negative max.
140
+ minValue: minVal - paddingRange,
138
141
  };
139
142
  };
140
143
 
@@ -67,13 +67,19 @@ export const Checkbox = (props) => {
67
67
 
68
68
  // Handle click
69
69
  if (!disabled) {
70
- checkbox.onclick = (e) => {
70
+ const handleClick = (e) => {
71
71
  e.stopPropagation();
72
72
  const newChecked = !internalChecked;
73
73
  internalChecked = newChecked;
74
74
  updateUI(newChecked);
75
75
  if (onPress) onPress(newChecked);
76
76
  };
77
+ // Route the handler through the factory's props instead of assigning
78
+ // `onclick` directly. `assignProps` clears `onclick` on every update and only
79
+ // re-applies handlers it can see in the merged props, so a direct assignment
80
+ // died on the first `update()` — and every reactive setter (opacity, bgColor,
81
+ // padding…) calls update — leaving the control unable to toggle ever again.
82
+ checkbox.update({ onPress: handleClick });
77
83
 
78
84
  // Hover effect
79
85
  checkbox.addEventListener("mouseenter", () => {
@@ -335,7 +335,10 @@ export const CircularChart = (props) => {
335
335
  if (!ctx || !canvas) return;
336
336
  if (!startTime) {
337
337
  startTime = timestamp;
338
- requestAnimationFrame(updateAnimation);
338
+ // Keep the id. The previous frame's id is already spent, so cleanup would
339
+ // cancel nothing and the pending frame kept re-arming forever on a detached
340
+ // canvas — and a re-`startAnimation()` could run two loops at once.
341
+ animationId = requestAnimationFrame(updateAnimation);
339
342
  return;
340
343
  }
341
344
  const elapsed = timestamp - startTime;
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { WidgetFactory } from "../widget-factory/index.js";
6
6
  import { colors } from "../utils/themes.js";
7
+ import { composeUpdate } from "../utils/composeUpdate.js";
7
8
  import { generateHighlightedHtml } from "../utils/syntaxHighlight.js";
8
9
 
9
10
  export const CodeViewer = (props) => {
@@ -81,8 +82,14 @@ code = "",
81
82
  ...rest,
82
83
  });
83
84
 
84
- // Header
85
- if (currentTitle && showHeader) {
85
+ // Header. Built through a helper so a later `title` update can create one on a
86
+ // widget that was constructed without it.
87
+ const ensureHeader = (text) => {
88
+ if (!showHeader || !text) return null;
89
+ if (headerRef) {
90
+ headerRef.textContent = text;
91
+ return headerRef;
92
+ }
86
93
  headerRef = WidgetFactory({
87
94
  tag: "div",
88
95
  padding: "8px 12px",
@@ -91,10 +98,12 @@ code = "",
91
98
  color: "#fff",
92
99
  fontSize: "14px",
93
100
  fontWeight: "bold",
94
- textContent: currentTitle,
101
+ textContent: text,
95
102
  });
96
- container.appendChild(headerRef);
97
- }
103
+ container.insertBefore(headerRef, container.firstChild);
104
+ return headerRef;
105
+ };
106
+ ensureHeader(currentTitle);
98
107
 
99
108
  // Scroll wrapper
100
109
  scrollWrapperRef = WidgetFactory({
@@ -209,11 +218,20 @@ code = "",
209
218
 
210
219
  container.updateTitle = (newTitle) => {
211
220
  currentTitle = newTitle;
212
- if (headerRef) {
213
- headerRef.textContent = currentTitle;
214
- }
221
+ ensureHeader(currentTitle);
215
222
  };
216
223
 
224
+ // `title` is a reactive prop mapped to `textContent` in the prop database, so
225
+ // letting the factory see it would replace the header and the whole code
226
+ // wrapper with a bare text node. Own it and update the header instead.
227
+ composeUpdate(container, ["title"], (props) => {
228
+ if (props.title === undefined) return;
229
+ container.updateTitle(props.title);
230
+ // `makeReactive` rewrites the value into `_originalProps` on every setter
231
+ // call; leaving it there means the next factory update wipes the widget.
232
+ delete container._originalProps.title;
233
+ });
234
+
217
235
  container.scrollTo = (x, y) => {
218
236
  if (scrollWrapperRef) {
219
237
  scrollWrapperRef.scrollLeft = x;
@@ -83,7 +83,9 @@ export const DataTable = (props) => {
83
83
  table.appendChild(thead);
84
84
 
85
85
  // ========== BODY ==========
86
- const tbody = document.createElement("tbody");
86
+ // `let`, not `const`: `updateData` replaces the body and must re-point here,
87
+ // otherwise the second call tries to replace an already-detached node.
88
+ let tbody = document.createElement("tbody");
87
89
 
88
90
  rows.forEach((row, index) => {
89
91
  const tr = document.createElement("tr");
@@ -152,8 +154,12 @@ export const DataTable = (props) => {
152
154
 
153
155
  // Update table data
154
156
  container.updateData = (newRows) => {
157
+ // Snapshot BEFORE clearing. `updateColumns` calls this with `rows` itself as
158
+ // the argument, so clearing first and then spreading produced an empty body
159
+ // and every row silently disappeared.
160
+ const nextRows = [...newRows];
155
161
  rows.length = 0;
156
- rows.push(...newRows);
162
+ rows.push(...nextRows);
157
163
 
158
164
  // Rebuild body
159
165
  const newTbody = document.createElement("tbody");
@@ -213,6 +219,9 @@ export const DataTable = (props) => {
213
219
  });
214
220
 
215
221
  table.replaceChild(newTbody, tbody);
222
+ // Re-point at the live node, otherwise the next call replaces an already
223
+ // detached node and throws NotFoundError.
224
+ tbody = newTbody;
216
225
  };
217
226
 
218
227
  // Update table columns
@@ -243,7 +243,11 @@ export const Dropdown = (props) => {
243
243
  if (outsideClickListener)
244
244
  document.removeEventListener("click", outsideClickListener);
245
245
  if (resizeListener) window.removeEventListener("resize", resizeListener);
246
- if (scrollListener) window.removeEventListener("scroll", scrollListener);
246
+ // The capture flag MUST match the one used when adding (see the `scroll`
247
+ // registration below), otherwise the listener is never removed: every
248
+ // open/close cycle leaked one window listener holding the whole closure.
249
+ if (scrollListener)
250
+ window.removeEventListener("scroll", scrollListener, true);
247
251
  outsideClickListener = resizeListener = scrollListener = null;
248
252
  };
249
253
 
@@ -5,10 +5,17 @@
5
5
  import { WidgetFactory } from "../widget-factory/index.js";
6
6
 
7
7
  export const Image = (props) => {
8
+ // `fit` and `circular` are documented Image props with no entry in the prop
9
+ // database, so forwarding them raw only produced an "unknown prop" warning
10
+ // while images stayed stretched (`object-fit: fill`) and square. Map them onto
11
+ // real CSS. `...rest` comes last so an explicit `borderRadius` still wins.
12
+ const { fit, circular, ...rest } = props || {};
8
13
  return WidgetFactory({
9
14
  tag: "img",
10
15
  widgetName: "Image",
11
- ...props,
16
+ ...(fit !== undefined ? { objectFit: fit } : null),
17
+ ...(circular ? { borderRadius: "50%" } : null),
18
+ ...rest,
12
19
  });
13
20
  };
14
21
 
@@ -6,6 +6,7 @@ import { WidgetFactory } from "../widget-factory/index.js";
6
6
  import { colors } from "../utils/themes.js";
7
7
  import { Icon } from "./Icon.js";
8
8
  import { TextInputValidator } from "../utils/TextInputValidator.js";
9
+ import { composeUpdate } from "../utils/composeUpdate.js";
9
10
 
10
11
  /**
11
12
  * @typedef {Object} InputProps
@@ -314,11 +315,16 @@ export const Input = (props) => {
314
315
  });
315
316
 
316
317
  // --- Optional label rendered above the input ---
317
- if (label) {
318
- const requiredMark = required ? " *" : "";
319
- const labelEl = WidgetFactory({
318
+ let labelEl = null;
319
+ const renderLabel = (text) => {
320
+ if (labelEl) {
321
+ labelEl.textContent = text;
322
+ return;
323
+ }
324
+ if (!text) return;
325
+ labelEl = WidgetFactory({
320
326
  tag: "label",
321
- text: label + requiredMark,
327
+ text,
322
328
  fontSize: 12,
323
329
  fontWeight: 500,
324
330
  color: error ? colors.danger : colors.textSecondary,
@@ -329,8 +335,23 @@ export const Input = (props) => {
329
335
  labelEl.addEventListener("click", () => {
330
336
  inputElement?.focus();
331
337
  });
332
- container.appendChild(labelEl);
333
- }
338
+ container.insertBefore(labelEl, container.firstChild);
339
+ };
340
+
341
+ if (label) renderLabel(label + (required ? " *" : ""));
342
+
343
+ // `label` is registered as a REACTIVE_PROP that maps to `textContent`, so if the
344
+ // factory ever sees it, `container.textContent = "…"` replaces this whole
345
+ // composite widget (label + wrapper + native <input> + icons) with a bare text
346
+ // node. Own the key and update the label element instead.
347
+ composeUpdate(container, ["label"], (props) => {
348
+ if (props.label === undefined) return;
349
+ renderLabel(props.label ? String(props.label) + (required ? " *" : "") : "");
350
+ // `makeReactive` writes the value back into `_originalProps` on every setter
351
+ // call; leaving it there means the next factory update merges it, maps it to
352
+ // `textContent` and wipes the widget anyway.
353
+ delete container._originalProps.label;
354
+ });
334
355
 
335
356
  // --- Wrapper — contains icons + native input, owns the border/fill style ---
336
357
  let wrapperStyles = {
@@ -162,6 +162,16 @@ export const Inspector = (widget, indent = 0) => {
162
162
  // Separate structural props from the rest so we can render them last
163
163
  const { child, children, ...otherProps } = props;
164
164
 
165
+ // `child` may legitimately be an ARRAY (`child: [Text(...)]`), which the
166
+ // factory accepts. Normalise it into the children list: otherwise the subtree
167
+ // is dropped and — because `child` is truthy — the DOM-children fallback below
168
+ // is skipped too, so the whole branch disappears from the output.
169
+ const childNode = Array.isArray(child) ? null : child;
170
+ const kids = [
171
+ ...(Array.isArray(child) ? child : []),
172
+ ...(Array.isArray(children) ? children : []),
173
+ ];
174
+
165
175
  const propsList = Object.entries(otherProps)
166
176
  .map(([key, value]) => {
167
177
  const formattedValue = stringifyValue(value, indent + 1);
@@ -171,14 +181,14 @@ export const Inspector = (widget, indent = 0) => {
171
181
 
172
182
  // Reconstruct a single `child` prop
173
183
  let childStr = "";
174
- if (child && child.nodeType === 1) {
175
- childStr = `\n${nextSpaces}child: ${Inspector(child, indent + 1)}`;
184
+ if (childNode && childNode.nodeType === 1) {
185
+ childStr = `\n${nextSpaces}child: ${Inspector(childNode, indent + 1)}`;
176
186
  }
177
187
 
178
- // Reconstruct a `children` array prop
188
+ // Reconstruct a `children` array prop (including an array-valued `child`)
179
189
  let childrenStr = "";
180
- if (children && Array.isArray(children) && children.length > 0) {
181
- const childrenItems = children
190
+ if (kids.length > 0) {
191
+ const childrenItems = kids
182
192
  .filter((c) => c && c.nodeType === 1)
183
193
  .map((c) => Inspector(c, indent + 1))
184
194
  .join(",\n" + nextSpaces + " ");
@@ -191,11 +201,7 @@ export const Inspector = (widget, indent = 0) => {
191
201
  // Fall back to actual DOM children when no explicit child/children props exist
192
202
  const domChildren = collectChildren(widget);
193
203
  let domChildrenStr = "";
194
- if (
195
- domChildren.length > 0 &&
196
- !child &&
197
- (!children || children.length === 0)
198
- ) {
204
+ if (domChildren.length > 0 && !childNode && kids.length === 0) {
199
205
  const domChildrenItems = domChildren
200
206
  .map((c) => Inspector(c, indent + 1))
201
207
  .join(",\n" + nextSpaces + " ");
@@ -407,8 +407,14 @@ export const ListView = (props) => {
407
407
  const updateData = (newData) => {
408
408
  _data = [...newData];
409
409
  itemCache.clear();
410
+ // The dataset changed but the viewport did not, so the previous window
411
+ // bounds would make `renderVisibleItems()` bail out and leave the OLD rows
412
+ // on screen for same-length data.
413
+ visibleStart = -1;
414
+ visibleEnd = -1;
410
415
  refreshUI();
411
- element.data = _data;
416
+ // Do NOT write `element.data` here: `data` is an accessor whose setter calls
417
+ // this very function, so assigning to it recursed until the stack overflowed.
412
418
  };
413
419
 
414
420
  /**
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { WidgetFactory } from "../widget-factory/index.js";
7
7
  import { colors } from "../utils/themes.js";
8
+ import { composeUpdate } from "../utils/composeUpdate.js";
8
9
  import { generateHighlightedHtml } from "../utils/syntaxHighlight.js";
9
10
 
10
11
  /**
@@ -36,19 +37,49 @@ let markdownScopeUid = 0;
36
37
  * @param {string} html - Raw HTML produced by `parseMarkdown`.
37
38
  * @returns {string} The sanitized HTML.
38
39
  */
40
+ /** True for URL values that must never reach an attribute (see sanitizeMarkdown). */
41
+ const isDangerousUrl = (value) => {
42
+ const v = String(value).replace(/[\u0000-\u0020]/g, "").toLowerCase();
43
+ if (v.startsWith("javascript:") || v.startsWith("vbscript:")) return true;
44
+ // Inline images are legitimate; every other `data:` URL is not.
45
+ return v.startsWith("data:") && !v.startsWith("data:image/");
46
+ };
47
+
39
48
  const sanitizeMarkdown = (html) => {
40
49
  const withoutScripts = html.replace(
41
50
  /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
42
51
  "",
43
52
  );
44
- return withoutScripts.replace(/<[^<>]+>/g, (tag) =>
45
- tag
46
- .replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
47
- .replace(
48
- /\s(href|src|xlink:href|formaction|action)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*'|javascript:[^\s>]+)/gi,
49
- ' $1="#"',
50
- ),
51
- );
53
+ // Parse for real and walk the tree. A regex over raw tags cannot see a `>`
54
+ // that sits *inside* a quoted attribute value, so `<img src="x>y" onerror=…>`
55
+ // used to pass through untouched while the browser kept the handler. Letting
56
+ // the browser tokenize removes that entire class of bypass.
57
+ try {
58
+ const doc = new DOMParser().parseFromString(withoutScripts, "text/html");
59
+ doc.querySelectorAll("script").forEach((node) => node.remove());
60
+ doc.querySelectorAll("*").forEach((node) => {
61
+ for (const attr of [...node.attributes]) {
62
+ if (attr.name.toLowerCase().startsWith("on")) {
63
+ node.removeAttribute(attr.name);
64
+ } else if (isDangerousUrl(attr.value)) {
65
+ // Neutralise rather than delete: the documented contract keeps the
66
+ // attribute and points it at "#", so links stay focusable and styled.
67
+ node.setAttribute(attr.name, "#");
68
+ }
69
+ }
70
+ });
71
+ return doc.body.innerHTML;
72
+ } catch {
73
+ // No DOMParser (non-browser host): keep the previous best-effort regex pass.
74
+ return withoutScripts.replace(/<[^<>]+>/g, (tag) =>
75
+ tag
76
+ .replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
77
+ .replace(
78
+ /\s(href|src|xlink:href|formaction|action)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*'|javascript:[^\s>]+)/gi,
79
+ ' $1="#"',
80
+ ),
81
+ );
82
+ }
52
83
  };
53
84
 
54
85
  // ─── Syntax highlighting ────────────────────────────────────────────────────
@@ -1230,6 +1261,20 @@ export const Markdown = (props) => {
1230
1261
  */
1231
1262
  container.getSource = () => markdownText;
1232
1263
 
1264
+ // `text`/`source`/`content` are reactive props mapped to `textContent` in the
1265
+ // prop database, so letting the factory see them replaced the rendered
1266
+ // Markdown with a bare text node. Own them and re-render instead.
1267
+ composeUpdate(container, ["text", "source", "content"], (props) => {
1268
+ const next = props.text ?? props.source ?? props.content;
1269
+ if (next !== undefined) container.updateContent(String(next));
1270
+ // `makeReactive` rewrites the value into `_originalProps` on every setter
1271
+ // call; leaving it there means the next factory update maps it to
1272
+ // `textContent` and wipes the widget anyway.
1273
+ for (const key of ["text", "source", "content"]) {
1274
+ delete container._originalProps[key];
1275
+ }
1276
+ });
1277
+
1233
1278
  return container;
1234
1279
  };
1235
1280
 
@@ -312,6 +312,8 @@ export const Modal = (props = {}) => {
312
312
 
313
313
  let isOpen = false;
314
314
  let overlay = null;
315
+ // Pending "hide after the close animation" timer, so it can be cancelled.
316
+ let closeTimer = null;
315
317
  let modalBody = null;
316
318
 
317
319
  // Handlers
@@ -323,7 +325,11 @@ export const Modal = (props = {}) => {
323
325
  modalBody.style.opacity = "0";
324
326
  }
325
327
  if (overlay) overlay.style.opacity = "0";
326
- setTimeout(() => {
328
+ // Keep the id: `handleOpen` clears it, otherwise a reopen within 300 ms gets
329
+ // a stale timer that hides an OPEN modal, leaving it invisible and unusable.
330
+ if (closeTimer) clearTimeout(closeTimer);
331
+ closeTimer = setTimeout(() => {
332
+ closeTimer = null;
327
333
  if (overlay) overlay.style.visibility = "hidden";
328
334
  }, 300);
329
335
  if (closeOnEsc) document.removeEventListener("keydown", handleKeyDown);
@@ -333,6 +339,11 @@ export const Modal = (props = {}) => {
333
339
  const handleOpen = () => {
334
340
  if (isOpen) return;
335
341
  isOpen = true;
342
+ // Cancel any pending hide from a close that is still animating out.
343
+ if (closeTimer) {
344
+ clearTimeout(closeTimer);
345
+ closeTimer = null;
346
+ }
336
347
  if (overlay) {
337
348
  overlay.style.visibility = "visible";
338
349
  overlay.style.opacity = "1";
@@ -405,6 +416,18 @@ export const Modal = (props = {}) => {
405
416
  overlay.appendChild(modalBody);
406
417
  document.body.appendChild(overlay);
407
418
 
419
+ // The overlay lives in `document.body` and can be removed without `close()`
420
+ // ever running (a state rebuild, a route change, a plain removeChild of the
421
+ // host). `_cleanup` is not called on that path, so release the document
422
+ // listener and any pending animation timer through the lifecycle hook too.
423
+ overlay.onUnmount(() => {
424
+ if (closeTimer) {
425
+ clearTimeout(closeTimer);
426
+ closeTimer = null;
427
+ }
428
+ if (closeOnEsc) document.removeEventListener("keydown", handleKeyDown);
429
+ });
430
+
408
431
  // Public API
409
432
  const instance = {
410
433
  open: handleOpen,
@@ -116,7 +116,9 @@ function buildQRMatrix(text, ecLevel = "M") {
116
116
  for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1);
117
117
  };
118
118
  pushBits(0b0100, 4); // mode indicator: byte
119
- pushBits(bytes.length, 8); // character count
119
+ // Character count: 8 bits for versions 1-9, 16 bits for versions 10-40
120
+ // (ISO/IEC 18004). Emitting 8 bits at version 10 misaligns every later bit.
121
+ pushBits(bytes.length, version < 10 ? 8 : 16);
120
122
  for (const b of bytes) pushBits(b, 8);
121
123
  // Terminator
122
124
  for (let i = 0; i < 4 && bits.length < dataCodewords * 8; i++) bits.push(0);
@@ -161,7 +163,12 @@ function buildQRMatrix(text, ecLevel = "M") {
161
163
 
162
164
  // 7. Build final bit sequence
163
165
  const finalBits = [];
164
- for (const cw of finalCW) pushBits(cw, 8);
166
+ // NOTE: `pushBits` writes into `bits` (the pre-interleave stream), so it must
167
+ // not be used here. Using it left `finalBits` empty, and the placement pass
168
+ // then wrote every data module as 0 — the symbol carried no payload at all.
169
+ for (const cw of finalCW) {
170
+ for (let i = 7; i >= 0; i--) finalBits.push((cw >> i) & 1);
171
+ }
165
172
  // Remainder bits (version-dependent, versions 2–6 need 7)
166
173
  const remainderBitsCount = version >= 2 && version <= 6 ? 7 : 0;
167
174
  for (let i = 0; i < remainderBitsCount; i++) finalBits.push(0);
@@ -67,7 +67,7 @@ export const Radio = (props) => {
67
67
 
68
68
  // Handle click
69
69
  if (!disabled) {
70
- radio.onclick = (e) => {
70
+ const handleClick = (e) => {
71
71
  e.stopPropagation();
72
72
  if (internalSelected) return;
73
73
 
@@ -88,6 +88,10 @@ export const Radio = (props) => {
88
88
 
89
89
  if (onPress) onPress(true);
90
90
  };
91
+ // Routed through the factory props: `assignProps` clears `onclick` on every
92
+ // update and only re-applies handlers present in the merged props, so a
93
+ // direct assignment died on the first `update()` or reactive setter.
94
+ radio.update({ onPress: handleClick });
91
95
 
92
96
  // Hover effect
93
97
  radio.addEventListener("mouseenter", () => {
@@ -299,6 +299,9 @@ export const Slider = (props) => {
299
299
 
300
300
  function generateDefaultMarks() {
301
301
  const stepMark = (currentMax - currentMin) / 4;
302
+ // With `min === max` (or non-numeric bounds) stepMark is 0/NaN, so
303
+ // `i += stepMark` never advances and the loop hangs the tab forever.
304
+ if (!(stepMark > 0)) return [currentMin];
302
305
  const marksList = [];
303
306
  for (let i = currentMin; i <= currentMax + 0.1; i += stepMark) {
304
307
  marksList.push(Math.round(i));
@@ -124,9 +124,18 @@ export function SnackBar(options) {
124
124
  });
125
125
 
126
126
  let timeoutId = null;
127
+ let rafId = null;
128
+ // `close()` is final: it must fire `onClose` exactly once and must win any
129
+ // race against the pending `show()` frame, otherwise closing a toast right
130
+ // after creating it would flash it back to opacity 1 for a full animation.
131
+ let closed = false;
127
132
 
128
133
  const show = () => {
129
- requestAnimationFrame(() => {
134
+ if (closed) return;
135
+ if (rafId !== null) cancelAnimationFrame(rafId);
136
+ rafId = requestAnimationFrame(() => {
137
+ rafId = null;
138
+ if (closed) return;
130
139
  snackbar.style.opacity = "1";
131
140
  snackbar.style.transform = "translateY(0)";
132
141
  });
@@ -134,7 +143,16 @@ export function SnackBar(options) {
134
143
  };
135
144
 
136
145
  const close = () => {
137
- if (timeoutId) clearTimeout(timeoutId);
146
+ if (closed) return;
147
+ closed = true;
148
+ if (timeoutId) {
149
+ clearTimeout(timeoutId);
150
+ timeoutId = null;
151
+ }
152
+ if (rafId !== null) {
153
+ cancelAnimationFrame(rafId);
154
+ rafId = null;
155
+ }
138
156
  snackbar.style.opacity = "0";
139
157
  snackbar.style.transform =
140
158
  position === "bottom"
@@ -79,13 +79,17 @@ export const Switch = (props) => {
79
79
 
80
80
  // Handle click
81
81
  if (!disabled) {
82
- switchContainer.onclick = (e) => {
82
+ const handleClick = (e) => {
83
83
  e.stopPropagation();
84
84
  const newValue = !internalValue;
85
85
  internalValue = newValue;
86
86
  updateUI(newValue);
87
87
  if (onPress) onPress(newValue);
88
88
  };
89
+ // Routed through the factory props: `assignProps` clears `onclick` on every
90
+ // update and only re-applies handlers present in the merged props, so a
91
+ // direct assignment died on the first `update()` or reactive setter.
92
+ switchContainer.update({ onPress: handleClick });
89
93
  }
90
94
 
91
95
  // Public methods
@@ -280,7 +280,9 @@ export const TreeView = (props) => {
280
280
 
281
281
  // ========== PUBLIC METHODS ==========
282
282
 
283
- container.updateNodes = (newNodes, selectedId = null) => {
283
+ // NOTE: no `selectedId = null` default. The guard below tests `!== undefined`,
284
+ // so a `null` default made it always true and every call wiped the selection.
285
+ container.updateNodes = (newNodes, selectedId) => {
284
286
  nodes.length = 0;
285
287
  nodes.push(...newNodes);
286
288
  if (selectedId !== undefined) currentSelectedId = selectedId;