flet-box 1.0.3 → 1.0.5

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 (172) hide show
  1. package/README.md +10 -0
  2. package/bin/cli.js +15 -0
  3. package/bin/commands/checkApi.js +761 -0
  4. package/bin/commands/create.js +77 -5
  5. package/bin/utils/dtsProps.js +191 -0
  6. package/bin/utils/moduleExports.js +165 -0
  7. package/bin/utils/neutralise.js +64 -0
  8. package/bin/utils/templates.js +366 -27
  9. package/docs/CONTRIBUTING.md +156 -0
  10. package/docs/README.md +68 -0
  11. package/docs/arquitectura.md +164 -0
  12. package/docs/cli/README.md +542 -0
  13. package/docs/guides/README.md +33 -0
  14. package/docs/guides/app-templates.md +197 -0
  15. package/docs/guides/compatibility.md +95 -0
  16. package/docs/guides/embedding.md +284 -0
  17. package/docs/guides/first-app.md +208 -0
  18. package/docs/guides/frontend-services.md +171 -0
  19. package/docs/guides/minification-and-protection.md +260 -0
  20. package/docs/guides/mobile-and-platforms.md +439 -0
  21. package/docs/guides/online-updates.md +196 -0
  22. package/docs/guides/router.md +282 -0
  23. package/docs/guides/state.md +283 -0
  24. package/docs/guides/syntax-philosophy.md +150 -0
  25. package/docs/guides/utilities.md +241 -0
  26. package/docs/guides/widget-structure.md +163 -0
  27. package/docs/server/README.md +68 -0
  28. package/docs/server/START_HERE.md +121 -0
  29. package/docs/server/api.md +225 -0
  30. package/docs/server/data-services.md +300 -0
  31. package/docs/server/deployment.md +113 -0
  32. package/docs/server/security.md +196 -0
  33. package/docs/services/README.md +79 -0
  34. package/docs/services/RamStore.md +84 -0
  35. package/docs/services/Session.md +84 -0
  36. package/docs/services/Storage.md +88 -0
  37. package/docs/services/http.md +86 -0
  38. package/docs/tools/README.md +138 -0
  39. package/docs/tools/animation.md +87 -0
  40. package/docs/tools/async-ids-log.md +80 -0
  41. package/docs/tools/device.md +132 -0
  42. package/docs/tools/introspection.md +67 -0
  43. package/docs/tools/lists.md +96 -0
  44. package/docs/tools/markdown.md +79 -0
  45. package/docs/tools/reactivity.md +288 -0
  46. package/docs/tools/state.md +103 -0
  47. package/docs/tools/styling.md +92 -0
  48. package/docs/tools/text-and-time.md +89 -0
  49. package/docs/tools/theme.md +102 -0
  50. package/docs/tools/validation.md +75 -0
  51. package/docs/tools/visual-effects.md +84 -0
  52. package/docs/widget/Accordion.md +144 -0
  53. package/docs/widget/AdMob.md +144 -0
  54. package/docs/widget/AdSense.md +133 -0
  55. package/docs/widget/AdaptiveScaffold.md +105 -0
  56. package/docs/widget/AlertDialog.md +103 -0
  57. package/docs/widget/AnimatedBox.md +130 -0
  58. package/docs/widget/AnimatedText.md +123 -0
  59. package/docs/widget/AppBar.md +118 -0
  60. package/docs/widget/Audio.md +135 -0
  61. package/docs/widget/Avatar.md +119 -0
  62. package/docs/widget/Badge.md +128 -0
  63. package/docs/widget/BottomNavigation.md +116 -0
  64. package/docs/widget/BottomSheet.md +125 -0
  65. package/docs/widget/Button.md +137 -0
  66. package/docs/widget/COMMON_PROPS.md +150 -0
  67. package/docs/widget/Card.md +125 -0
  68. package/docs/widget/Carousel.md +151 -0
  69. package/docs/widget/Chart.md +151 -0
  70. package/docs/widget/Checkbox.md +118 -0
  71. package/docs/widget/Chip.md +126 -0
  72. package/docs/widget/CircularBar.md +168 -0
  73. package/docs/widget/CircularChart.md +164 -0
  74. package/docs/widget/CodeViewer.md +130 -0
  75. package/docs/widget/CollapsibleSideBar.md +112 -0
  76. package/docs/widget/Column.md +129 -0
  77. package/docs/widget/Container.md +136 -0
  78. package/docs/widget/DataTable.md +150 -0
  79. package/docs/widget/Divider.md +109 -0
  80. package/docs/widget/DraggBox.md +129 -0
  81. package/docs/widget/Drawer.md +135 -0
  82. package/docs/widget/DrawerItem.md +98 -0
  83. package/docs/widget/Dropdown.md +139 -0
  84. package/docs/widget/DroppBox.md +152 -0
  85. package/docs/widget/FloatingActionButton.md +120 -0
  86. package/docs/widget/GridView.md +140 -0
  87. package/docs/widget/Icon.md +117 -0
  88. package/docs/widget/Image.md +125 -0
  89. package/docs/widget/Input.md +151 -0
  90. package/docs/widget/Inspector.md +92 -0
  91. package/docs/widget/InstallButton.md +128 -0
  92. package/docs/widget/ListTile.md +132 -0
  93. package/docs/widget/ListView.md +138 -0
  94. package/docs/widget/Markdown.md +175 -0
  95. package/docs/widget/MatrixRain.md +122 -0
  96. package/docs/widget/Modal.md +129 -0
  97. package/docs/widget/Pagination.md +135 -0
  98. package/docs/widget/ParallaxBox.md +128 -0
  99. package/docs/widget/ProgressBar.md +130 -0
  100. package/docs/widget/QRCode.md +117 -0
  101. package/docs/widget/README.md +188 -0
  102. package/docs/widget/Radio.md +123 -0
  103. package/docs/widget/Rating.md +131 -0
  104. package/docs/widget/Row.md +131 -0
  105. package/docs/widget/START_HERE.md +146 -0
  106. package/docs/widget/Scaffold.md +152 -0
  107. package/docs/widget/Skeleton.md +130 -0
  108. package/docs/widget/Slider.md +146 -0
  109. package/docs/widget/SnackBar.md +102 -0
  110. package/docs/widget/Stack.md +128 -0
  111. package/docs/widget/Stepper.md +145 -0
  112. package/docs/widget/Switch.md +118 -0
  113. package/docs/widget/Tabs.md +124 -0
  114. package/docs/widget/Text.md +133 -0
  115. package/docs/widget/Tooltip.md +145 -0
  116. package/docs/widget/TreeView.md +176 -0
  117. package/docs/widget/Video.md +127 -0
  118. package/docs/widget/_template.md +61 -0
  119. package/package.json +4 -2
  120. package/src/animations/AnimatedBox.js +32 -1
  121. package/src/animations/AnimatedText.js +7 -3
  122. package/src/animations/MatrixRain.js +37 -12
  123. package/src/animations/ParallaxBox.js +16 -3
  124. package/src/animations/animate.js +187 -58
  125. package/src/animations/index.js +1 -0
  126. package/src/core/runApp.js +13 -1
  127. package/src/create-index.sh +1 -1
  128. package/src/index.d.ts +227 -91
  129. package/src/index.js +1 -0
  130. package/src/navigations/AdaptiveScaffold.js +11 -2
  131. package/src/navigations/AppBar.js +8 -2
  132. package/src/navigations/BottomNavigation.js +24 -5
  133. package/src/navigations/DrawerItem.js +16 -3
  134. package/src/navigations/Scaffold.js +8 -1
  135. package/src/navigations/Tabs.js +40 -8
  136. package/src/tools/dimensions.js +11 -3
  137. package/src/tools/useState.js +139 -101
  138. package/src/utils/getWidgetProps.js +5 -3
  139. package/src/widget-factory/assignProps.js +30 -11
  140. package/src/widget-factory/processProps.js +17 -5
  141. package/src/widget-factory/reactiveValue.js +172 -0
  142. package/src/widget-factory/translateProps.js +777 -901
  143. package/src/widget-factory/warnings.js +15 -0
  144. package/src/widget-factory/widgetFactory.js +27 -0
  145. package/src/widgets/Accordion.js +6 -5
  146. package/src/widgets/AlertDialog.js +7 -5
  147. package/src/widgets/Audio.js +30 -8
  148. package/src/widgets/BottomSheet.js +24 -1
  149. package/src/widgets/Chart.js +4 -1
  150. package/src/widgets/Checkbox.js +16 -8
  151. package/src/widgets/CircularBar.js +12 -1
  152. package/src/widgets/CircularChart.js +4 -1
  153. package/src/widgets/CodeViewer.js +26 -8
  154. package/src/widgets/DataTable.js +11 -2
  155. package/src/widgets/Dropdown.js +5 -1
  156. package/src/widgets/FloatingActionButton.js +4 -7
  157. package/src/widgets/Image.js +7 -2
  158. package/src/widgets/Input.js +119 -6
  159. package/src/widgets/Inspector.js +16 -10
  160. package/src/widgets/ListView.js +80 -30
  161. package/src/widgets/Markdown.js +53 -8
  162. package/src/widgets/Modal.js +24 -1
  163. package/src/widgets/Pagination.js +6 -3
  164. package/src/widgets/ProgressBar.js +16 -13
  165. package/src/widgets/QRCode.js +9 -2
  166. package/src/widgets/Radio.js +11 -8
  167. package/src/widgets/Slider.js +26 -4
  168. package/src/widgets/SnackBar.js +20 -2
  169. package/src/widgets/Stack.js +36 -3
  170. package/src/widgets/Stepper.js +4 -3
  171. package/src/widgets/Switch.js +8 -5
  172. package/src/widgets/TreeView.js +3 -2
@@ -7,6 +7,14 @@ const IS_DEV = typeof FLETBOX_DEV !== 'undefined' ? FLETBOX_DEV : true;
7
7
  const suggestionCache = new Map();
8
8
  let propNames = [];
9
9
 
10
+ // Props already reported. Without this, every prop *application* re-printed the
11
+ // whole block — a captured stack trace plus ~8 styled console calls — so a widget
12
+ // carrying a single unknown prop paid ~137 µs on every `update()`, roughly 400×
13
+ // the cost of a known prop. Because `update()` re-applies every original prop, one
14
+ // unknown prop slowed that widget for its whole lifetime.
15
+ const warnedProps = new Set();
16
+ const warnedDeprecated = new Set();
17
+
10
18
  export function setPropNames(names) {
11
19
  propNames = names;
12
20
  try { if (typeof window !== 'undefined') window.__FLETBOX_PROP_NAMES = names; } catch(e){}
@@ -216,6 +224,9 @@ function printDocs(prop) {
216
224
 
217
225
  export function warnUnknownProp(propName, context = {}) {
218
226
  if (!IS_DEV) return;
227
+ // Report each unknown prop once, not once per application.
228
+ if (warnedProps.has(propName)) return;
229
+ warnedProps.add(propName);
219
230
 
220
231
  const suggestions = getSuggestions(propName);
221
232
  const location = getCallLocation();
@@ -231,6 +242,10 @@ export function warnUnknownProp(propName, context = {}) {
231
242
 
232
243
  export function warnDeprecatedProp(propName, replacement = null, context = {}) {
233
244
  if (!IS_DEV) return;
245
+ // Same reasoning as `warnUnknownProp`: one report per prop/replacement pair.
246
+ const deprecatedKey = `${propName}\u0000${replacement || ""}`;
247
+ if (warnedDeprecated.has(deprecatedKey)) return;
248
+ warnedDeprecated.add(deprecatedKey);
234
249
 
235
250
  const location = getCallLocation();
236
251
 
@@ -8,6 +8,29 @@ import { processProps } from "./processProps.js";
8
8
  import { assignProps } from "./assignProps.js";
9
9
  import { stackPosition } from "./stackPosition.js";
10
10
  import { addChildren, removePropChildren } from "./addChildren.js";
11
+ import { reactiveKeyOf } from "./reactiveValue.js";
12
+ import { registerWidget } from "../tools/useState.js";
13
+
14
+ /**
15
+ * Auto-binds reactive props to their state key.
16
+ *
17
+ * A prop whose value is a live reactive (what `useState` returns) carries the key
18
+ * it came from, so the factory can subscribe this widget to that key. A later
19
+ * change then updates **only this widget** instead of rebuilding the whole app —
20
+ * which is what makes the plain
21
+ * `const [color, setColor] = useState("color", "red"); Widget({ bgColor: color })`
22
+ * syntax surgical.
23
+ *
24
+ * @param {HTMLElement} widget - The widget being built or updated.
25
+ * @param {Object} props - The props to scan.
26
+ * @returns {void}
27
+ */
28
+ const bindReactiveProps = (widget, props) => {
29
+ for (const [prop, value] of Object.entries(props || {})) {
30
+ const key = reactiveKeyOf(value);
31
+ if (key) registerWidget(key, widget, prop);
32
+ }
33
+ };
11
34
 
12
35
  /**
13
36
  * Main widget factory. Creates a fully functional DOM widget with reactivity,
@@ -96,6 +119,7 @@ export function WidgetFactory(tag, customProps = {}) {
96
119
  // ============================================================
97
120
  const initialProps = processProps(finalProps, finalTag, widgetName);
98
121
  assignProps(widget, initialProps);
122
+ bindReactiveProps(widget, finalProps);
99
123
  stackPosition(widget, finalProps);
100
124
 
101
125
  if (finalProps.child !== undefined) {
@@ -160,6 +184,9 @@ export function WidgetFactory(tag, customProps = {}) {
160
184
 
161
185
  const processed = processProps(merged, finalTag, widgetName);
162
186
  assignProps(widget, processed);
187
+ // Only the incoming keys can introduce a new binding; the ones present at
188
+ // construction were registered then.
189
+ bindReactiveProps(widget, normalized);
163
190
  stackPosition(widget, merged);
164
191
 
165
192
  if (hasChild || hasChildren) {
@@ -156,11 +156,12 @@ export const Accordion = (props) => {
156
156
  titleElement.style.fontSize = `${currentTitleSize}px`;
157
157
  titleElement.style.fontWeight = currentTitleWeight;
158
158
 
159
- // Swap icon based on current expanded state
160
- iconElement.setAttribute(
161
- "name",
162
- isExpanded ? currentIconExpanded : currentIconCollapsed,
163
- );
159
+ // Swap icon based on current expanded state. `Icon` renders its glyph as
160
+ // TEXT (a material-icons ligature), so setting a `name` attribute changed
161
+ // nothing and the chevron stayed on `chevron_right` forever.
162
+ iconElement.textContent = isExpanded
163
+ ? currentIconExpanded
164
+ : currentIconCollapsed;
164
165
  iconElement.style.color = currentIconColor;
165
166
  iconElement.style.fontSize = `${currentIconSize}px`;
166
167
 
@@ -40,7 +40,7 @@ export const AlertDialog = (props) => {
40
40
  onConfirm,
41
41
  onCancel,
42
42
  onClose,
43
- variant = "normal", // 'normal', 'danger', 'warning', 'success'
43
+ variant = "normal",
44
44
  showCancel = true,
45
45
  confirmText = "Accept",
46
46
  cancelText = "Cancel",
@@ -83,14 +83,16 @@ export const AlertDialog = (props) => {
83
83
  children: [
84
84
  icon,
85
85
  Text({
86
- value: title,
86
+ // FIX: `value` no aplica a <span> → título quedaba vacío. Usar `text`.
87
+ text: title,
87
88
  size: 24,
88
89
  fontWeight: "bold",
89
90
  color: colors.text,
90
91
  textAlign: "center",
91
92
  }),
92
93
  Text({
93
- value: message,
94
+ // FIX: idem para el mensaje.
95
+ text: message,
94
96
  size: 18,
95
97
  fontWeight: "bold",
96
98
  color: colors.textSecondary,
@@ -134,7 +136,7 @@ export const AlertDialog = (props) => {
134
136
 
135
137
  let modal = null;
136
138
  // Placeholder until the modal is created below — avoids a temporal dead-zone
137
- let closeModal = () => {};
139
+ let closeModal = () => { };
138
140
 
139
141
  // Delegate to the base Modal widget, passing our content and actions
140
142
  modal = Modal({
@@ -159,4 +161,4 @@ export const AlertDialog = (props) => {
159
161
  return { open, close, modal };
160
162
  };
161
163
 
162
- export default AlertDialog;
164
+ export default AlertDialog;
@@ -74,6 +74,9 @@ export const Audio = (props) => {
74
74
  let duration = 0;
75
75
  let durationPoll = null;
76
76
  let audioContextFallbackAttempted = false;
77
+ // Set on unmount so deferred work (the duration fallback, in-flight fetches)
78
+ // never touches a discarded widget.
79
+ let disposed = false;
77
80
 
78
81
  /**
79
82
  * Recalculates the current playback position and fires the progress callbacks.
@@ -139,19 +142,25 @@ export const Audio = (props) => {
139
142
  * cannot determine duration without fully downloading the file.
140
143
  */
141
144
  const fallbackGetDuration = async () => {
142
- if (audioContextFallbackAttempted) return;
145
+ if (audioContextFallbackAttempted || disposed) return;
143
146
  audioContextFallbackAttempted = true;
144
147
 
145
148
  try {
146
149
  const response = await fetch(src);
147
150
  const arrayBuffer = await response.arrayBuffer();
151
+ if (disposed) return;
148
152
  const audioContext = new (
149
153
  window.AudioContext || window.webkitAudioContext
150
154
  )();
151
- const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
152
- const durationSeconds = audioBuffer.duration;
153
- setDuration(durationSeconds);
154
- audioContext.close();
155
+ // `close()` must run even when decoding fails: AudioContexts are a capped
156
+ // resource (~6 per page in Chromium) and leaking them makes every later
157
+ // context fail to start, which breaks the fallback for good.
158
+ try {
159
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
160
+ setDuration(audioBuffer.duration);
161
+ } finally {
162
+ audioContext.close();
163
+ }
155
164
  } catch (err) {
156
165
  console.warn("[Audio] Fallback duration fetch failed:", err);
157
166
  }
@@ -178,16 +187,29 @@ export const Audio = (props) => {
178
187
  audio.addEventListener("error", (e) => console.warn("[Audio] Error:", e));
179
188
 
180
189
  // Kick off duration detection immediately; if unavailable after 2 s, use fetch fallback
190
+ let fallbackTimer = null;
181
191
  if (!tryGetDurationFromAudio()) {
182
192
  pollDuration();
183
- setTimeout(() => {
193
+ fallbackTimer = setTimeout(() => {
194
+ fallbackTimer = null;
184
195
  if (!duration) fallbackGetDuration();
185
196
  }, 2000);
186
197
  }
187
198
 
188
- // Cancel the duration polling interval when the widget unmounts
199
+ // Release everything the widget owns on unmount. The 2 s fallback timer was
200
+ // never stored, so after teardown it still fetched the whole file and spun up
201
+ // an AudioContext (and called onLoad) for a widget that no longer exists — and
202
+ // a tree rebuild does that on every state change.
189
203
  audio.onUnmount(() => {
190
- if (durationPoll) clearInterval(durationPoll);
204
+ disposed = true;
205
+ if (durationPoll) {
206
+ clearInterval(durationPoll);
207
+ durationPoll = null;
208
+ }
209
+ if (fallbackTimer) {
210
+ clearTimeout(fallbackTimer);
211
+ fallbackTimer = null;
212
+ }
191
213
  });
192
214
 
193
215
  // ========== PUBLIC PLAYBACK API ==========
@@ -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
 
@@ -12,6 +12,7 @@ export const Checkbox = (props) => {
12
12
  onPress: onPressProp,
13
13
  disabled = false,
14
14
  size = 20,
15
+ activeColor = colors.primary,
15
16
  ...rest
16
17
  } = props;
17
18
 
@@ -25,10 +26,8 @@ export const Checkbox = (props) => {
25
26
 
26
27
  const updateUI = (newChecked) => {
27
28
  if (checkboxRef) {
28
- checkboxRef.style.backgroundColor = newChecked
29
- ? colors.primary
30
- : "transparent";
31
- checkboxRef.style.border = `2px solid ${newChecked ? colors.primary : colors.border}`;
29
+ checkboxRef.style.backgroundColor = newChecked ? activeColor : "transparent";
30
+ checkboxRef.style.border = `2px solid ${newChecked ? activeColor : colors.border}`;
32
31
  }
33
32
  if (checkMarkRef) {
34
33
  checkMarkRef.style.display = newChecked ? "inline-block" : "none";
@@ -51,9 +50,9 @@ export const Checkbox = (props) => {
51
50
  widgetName: "Checkbox",
52
51
  width: size,
53
52
  height: size,
54
- border: `2px solid ${disabled ? colors.border : internalChecked ? colors.primary : colors.border}`,
53
+ border: `2px solid ${disabled ? colors.border : internalChecked ? activeColor : colors.border}`,
55
54
  borderRadius: 6,
56
- backgroundColor: internalChecked ? colors.primary : "transparent",
55
+ backgroundColor: internalChecked ? activeColor : "transparent",
57
56
  display: "flex",
58
57
  alignItems: "center",
59
58
  justifyContent: "center",
@@ -67,18 +66,27 @@ export const Checkbox = (props) => {
67
66
 
68
67
  // Handle click
69
68
  if (!disabled) {
70
- checkbox.onclick = (e) => {
69
+ const handleClick = (e) => {
71
70
  e.stopPropagation();
72
71
  const newChecked = !internalChecked;
73
72
  internalChecked = newChecked;
74
73
  updateUI(newChecked);
75
74
  if (onPress) onPress(newChecked);
76
75
  };
76
+ // Route the handler through the factory's props instead of assigning
77
+ // `onclick` directly. `assignProps` clears `onclick` on every update and only
78
+ // re-applies handlers it can see in the merged props, so a direct assignment
79
+ // died on the first `update()` — and every reactive setter (opacity, bgColor,
80
+ // padding…) calls update — leaving the control unable to toggle ever again.
81
+ checkbox.update({ onPress: handleClick });
77
82
 
78
83
  // Hover effect
79
84
  checkbox.addEventListener("mouseenter", () => {
80
85
  if (!disabled && !internalChecked) {
81
- checkbox.style.backgroundColor = colors.primary + "20";
86
+ // `color-mix` rather than `activeColor + "20"`: that string concat only
87
+ // produced a valid color for `#rrggbb` hexes and silently broke on
88
+ // `rgba()`, named colors and 3-digit hex `activeColor` values.
89
+ checkbox.style.backgroundColor = `color-mix(in srgb, ${activeColor} 12%, transparent)`;
82
90
  }
83
91
  });
84
92
 
@@ -104,7 +104,18 @@ export const CircularBar = (props) => {
104
104
  if (!gradient) return color;
105
105
  if (typeof gradient === "string") return gradient;
106
106
  if (Array.isArray(gradient)) {
107
- const grad = ctx.createLinearGradient(0, 0, currentWidth, currentWidth);
107
+ // CSS angle convention: 0deg points up and angles grow clockwise, so the
108
+ // default 135deg keeps the historical top-left to bottom-right sweep.
109
+ const rad = (gradientAngle * Math.PI) / 180;
110
+ const centre = currentWidth / 2;
111
+ const dx = Math.sin(rad) * centre;
112
+ const dy = -Math.cos(rad) * centre;
113
+ const grad = ctx.createLinearGradient(
114
+ centre - dx,
115
+ centre - dy,
116
+ centre + dx,
117
+ centre + dy,
118
+ );
108
119
  gradient.forEach((c, i) => {
109
120
  grad.addColorStop(i / (gradient.length - 1), c);
110
121
  });
@@ -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
 
@@ -2,11 +2,6 @@
2
2
  * @file FloatingActionButton.js
3
3
  * @description Circular floating action button anchored to a corner.
4
4
  */
5
- /**
6
- * FloatingActionButton Component
7
- * @module widgets/FloatingActionButton
8
- */
9
-
10
5
  import { Container } from "./Container.js";
11
6
  import { Row } from "./Row.js";
12
7
  import { Text } from "./Text.js";
@@ -45,7 +40,9 @@ export const FloatingActionButton = (props) => {
45
40
  if (extended && label) {
46
41
  children.push(
47
42
  Text({
48
- value: label,
43
+ // FIX: `value` es special y `applySpecial` solo lo aplica en inputs
44
+ // → en un <span> no hacía nada y el label quedaba vacío.
45
+ text: label,
49
46
  size: 14,
50
47
  fontWeight: "500",
51
48
  color: foregroundColor,
@@ -88,4 +85,4 @@ export const FloatingActionButton = (props) => {
88
85
  return button;
89
86
  };
90
87
 
91
- export default FloatingActionButton;
88
+ export default FloatingActionButton;
@@ -5,11 +5,16 @@
5
5
  import { WidgetFactory } from "../widget-factory/index.js";
6
6
 
7
7
  export const Image = (props) => {
8
+ // `fit` is now a proper style prop in translateProps (→ object-fit), so it
9
+ // flows through the factory like any other CSS property. Only `circular`
10
+ // needs special handling — it maps to borderRadius 50%.
11
+ const { circular, ...rest } = props || {};
8
12
  return WidgetFactory({
9
13
  tag: "img",
10
14
  widgetName: "Image",
11
- ...props,
15
+ ...(circular ? { borderRadius: "50%" } : null),
16
+ ...rest,
12
17
  });
13
18
  };
14
19
 
15
- export default Image;
20
+ export default Image;