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
@@ -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
@@ -25,6 +26,9 @@ import { TextInputValidator } from "../utils/TextInputValidator.js";
25
26
  * @property {string} [iconColor] - Color of the icons.
26
27
  * @property {number} [iconSize=20] - Size of the icons in pixels.
27
28
  * @property {Function} [onIconPress] - Called when either icon is pressed.
29
+ * @property {boolean} [clearable=false] - Shows a trailing button that empties the field.
30
+ * @property {boolean} [passwordToggle=false] - Shows a trailing button that reveals or hides the value.
31
+ * @property {Function} [onEnter] - Called with (value, event) when the user presses Enter in the field.
28
32
  * @property {"none"|"letters"|"numbers"|"email"|"alphanumeric"|"safe"|"custom"} [validation="none"] - Validation mode applied on each keystroke and on blur.
29
33
  * @property {number} [maxLength] - Maximum number of characters allowed.
30
34
  * @property {boolean} [required=false] - Marks the field as required; validation fails on empty values.
@@ -38,6 +42,9 @@ import { TextInputValidator } from "../utils/TextInputValidator.js";
38
42
  * @property {Function} [onBlur] - Called when the input loses focus and final validation runs.
39
43
  */
40
44
 
45
+ /** Suffix for generated field ids, so two Inputs never share one. */
46
+ let inputIdSeed = 0;
47
+
41
48
  /**
42
49
  * A fully-featured text input widget with built-in validation, icons, and theme support.
43
50
  *
@@ -77,6 +84,9 @@ export const Input = (props) => {
77
84
  iconSize = 20,
78
85
  onIconPress,
79
86
 
87
+ clearable = false,
88
+ passwordToggle = false,
89
+
80
90
  validation = "none",
81
91
  maxLength,
82
92
  required = false,
@@ -89,6 +99,7 @@ export const Input = (props) => {
89
99
  onChange,
90
100
  onFocus,
91
101
  onBlur,
102
+ onEnter,
92
103
  ...rest
93
104
  } = props;
94
105
 
@@ -97,12 +108,22 @@ export const Input = (props) => {
97
108
  let isValid = true;
98
109
  let validationMessage = "";
99
110
 
111
+ // The `<label>` needs a `for` that matches the native input's `id`, otherwise
112
+ // assistive tech never hears the field's name — the click-to-focus handler is
113
+ // not an association. Deriving it from the caller's `id` keeps the generated
114
+ // DOM predictable when the field is inside a form.
115
+ const fieldId =
116
+ typeof rest.id === "string" && rest.id
117
+ ? `${rest.id}-input`
118
+ : `fletbox-input-${++inputIdSeed}`;
119
+
100
120
  // DOM element references — populated during the build phase below
101
121
  let inputElement = null;
102
122
  let validationIcon = null;
103
123
  let errorMessageElement = null;
104
124
  let inputWrapper = null;
105
125
  let container = null;
126
+ let clearButton = null;
106
127
 
107
128
  // Whether the native input currently holds focus. Used to keep the focus
108
129
  // ring (a box-shadow) in sync with validation colors without ever changing
@@ -285,6 +306,7 @@ export const Input = (props) => {
285
306
  if (inputElement) {
286
307
  inputElement.value = filtered;
287
308
  }
309
+ if (clearButton) clearButton.style.display = filtered ? "flex" : "none";
288
310
 
289
311
  const result = validateValue(filtered);
290
312
  isValid = result.valid;
@@ -314,11 +336,17 @@ export const Input = (props) => {
314
336
  });
315
337
 
316
338
  // --- Optional label rendered above the input ---
317
- if (label) {
318
- const requiredMark = required ? " *" : "";
319
- const labelEl = WidgetFactory({
339
+ let labelEl = null;
340
+ const renderLabel = (text) => {
341
+ if (labelEl) {
342
+ labelEl.textContent = text;
343
+ return;
344
+ }
345
+ if (!text) return;
346
+ labelEl = WidgetFactory({
320
347
  tag: "label",
321
- text: label + requiredMark,
348
+ for: fieldId,
349
+ text,
322
350
  fontSize: 12,
323
351
  fontWeight: 500,
324
352
  color: error ? colors.danger : colors.textSecondary,
@@ -329,8 +357,23 @@ export const Input = (props) => {
329
357
  labelEl.addEventListener("click", () => {
330
358
  inputElement?.focus();
331
359
  });
332
- container.appendChild(labelEl);
333
- }
360
+ container.insertBefore(labelEl, container.firstChild);
361
+ };
362
+
363
+ if (label) renderLabel(label + (required ? " *" : ""));
364
+
365
+ // `label` is registered as a REACTIVE_PROP that maps to `textContent`, so if the
366
+ // factory ever sees it, `container.textContent = "…"` replaces this whole
367
+ // composite widget (label + wrapper + native <input> + icons) with a bare text
368
+ // node. Own the key and update the label element instead.
369
+ composeUpdate(container, ["label"], (props) => {
370
+ if (props.label === undefined) return;
371
+ renderLabel(props.label ? String(props.label) + (required ? " *" : "") : "");
372
+ // `makeReactive` writes the value back into `_originalProps` on every setter
373
+ // call; leaving it there means the next factory update merges it, maps it to
374
+ // `textContent` and wipes the widget anyway.
375
+ delete container._originalProps.label;
376
+ });
334
377
 
335
378
  // --- Wrapper — contains icons + native input, owns the border/fill style ---
336
379
  let wrapperStyles = {
@@ -385,6 +428,7 @@ export const Input = (props) => {
385
428
  // - Accessibility (label association, screen readers)
386
429
  // - Browser autofill support
387
430
  inputElement = document.createElement("input");
431
+ inputElement.id = fieldId;
388
432
  inputElement.type = type;
389
433
  inputElement.placeholder = placeholder || "";
390
434
  inputElement.disabled = disabled || false;
@@ -447,6 +491,75 @@ export const Input = (props) => {
447
491
 
448
492
  inputWrapper.appendChild(inputElement);
449
493
 
494
+ inputElement.addEventListener("keydown", (e) => {
495
+ // Enter is not a DOM event of its own: the callback receives the value the
496
+ // user just confirmed, after the same filtering `onChange` applies.
497
+ if (e.key === "Enter" && onEnter) onEnter(currentValue, e);
498
+ });
499
+
500
+ // --- Trailing controls (clear / reveal) ---
501
+ // Real `<button type="button">` elements: they are keyboard-reachable, and the
502
+ // `type` keeps them from submitting a form the input happens to live in.
503
+ const makeTrailingButton = (glyph, label, onPress) => {
504
+ const btn = document.createElement("button");
505
+ btn.type = "button";
506
+ btn.textContent = glyph;
507
+ btn.setAttribute("aria-label", label);
508
+ btn.style.width = "28px";
509
+ btn.style.height = "28px";
510
+ btn.style.flexShrink = "0";
511
+ btn.style.marginRight = "6px";
512
+ btn.style.padding = "0";
513
+ btn.style.border = "none";
514
+ btn.style.background = "transparent";
515
+ btn.style.color = colors.textSecondary;
516
+ btn.style.fontSize = "15px";
517
+ btn.style.lineHeight = "1";
518
+ btn.style.cursor = disabled ? "not-allowed" : "pointer";
519
+ btn.style.display = "flex";
520
+ btn.style.alignItems = "center";
521
+ btn.style.justifyContent = "center";
522
+ btn.addEventListener("click", (e) => {
523
+ // The wrapper focuses the input on any click; a trailing control must not
524
+ // steal that, and it must stay out of the form's submit path.
525
+ e.preventDefault();
526
+ e.stopPropagation();
527
+ if (disabled || readonly) return;
528
+ onPress(e);
529
+ });
530
+ inputWrapper.appendChild(btn);
531
+ return btn;
532
+ };
533
+
534
+ if (clearable) {
535
+ clearButton = makeTrailingButton("✕", "Clear", () => {
536
+ handleInput("", null);
537
+ inputElement.focus();
538
+ });
539
+ clearButton.style.display = currentValue ? "flex" : "none";
540
+ }
541
+
542
+ if (passwordToggle) {
543
+ let revealed = type !== "password";
544
+ const paint = (btn) => {
545
+ btn.textContent = revealed ? "🙈" : "👁";
546
+ btn.setAttribute("aria-label", revealed ? "Hide value" : "Show value");
547
+ };
548
+ const btn = makeTrailingButton("👁", "Show value", () => {
549
+ revealed = !revealed;
550
+ inputElement.type = revealed ? "text" : "password";
551
+ paint(btn);
552
+ // Keep the caret where the user left it: swapping the type recreates the
553
+ // native field's rendering, so refocusing without restoring the selection
554
+ // would jump the cursor to the end.
555
+ const end = inputElement.value.length;
556
+ inputElement.setSelectionRange?.(end, end);
557
+ inputElement.focus();
558
+ });
559
+ paint(btn);
560
+ // `inputElement.type` was already set from `type`; the button only mirrors it.
561
+ }
562
+
450
563
  // --- Inline validation icon (✓ / ✗) shown inside the input on the right ---
451
564
  if (showValidationIcon && validation !== "none") {
452
565
  validationIcon = document.createElement("span");
@@ -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 + " ");
@@ -128,6 +128,9 @@ export const ListView = (props) => {
128
128
  let visibleStart = 0,
129
129
  visibleEnd = 0; // current render window (avoids redundant re-renders when nothing changed)
130
130
  let ticking = false; // requestAnimationFrame guard for the scroll handler
131
+ let scrollRaf = 0; // id of the frame the scroll handler queued, so unmount can cancel it
132
+ let mountRaf = 0; // id of the first-render frame
133
+ let disposed = false; // after unmount no queued frame may touch the DOM or call back
131
134
  let headerElement = null,
132
135
  footerElement = null,
133
136
  emptyElement = null;
@@ -140,27 +143,35 @@ export const ListView = (props) => {
140
143
  /** @returns {number} Total number of rows (accounting for grid columns). */
141
144
  const getTotalRows = () => Math.ceil(_data.length / cols);
142
145
 
146
+ /** @returns {number} Height the header occupies inside the scroll container. */
147
+ const getHeaderHeight = () => headerElement?.offsetHeight || 0;
148
+
143
149
  /**
144
- * Calculates the absolute top offset of an item, accounting for the header height.
150
+ * Calculates the absolute top offset of an item inside `innerContainer`.
151
+ *
152
+ * The header is a SIBLING placed above `innerContainer`, so the browser already
153
+ * pushes every row down by the header's height. Adding it here counted it twice:
154
+ * rows sat one header below where the window math expected them, and
155
+ * `scrollToIndex` overshot by the same amount.
156
+ *
145
157
  * @param {number} index - Zero-based item index.
146
158
  * @returns {number} Top position in pixels.
147
159
  */
148
160
  const getItemTop = (index) => {
149
161
  const row = Math.floor(index / cols);
150
- const headerHeight = headerElement?.offsetHeight || 0;
151
- return headerHeight + row * (itemSize + gap);
162
+ return row * (itemSize + gap);
152
163
  };
153
164
 
154
165
  /**
155
- * Calculates the total logical height of the list including header and footer.
156
- * This value is set on `innerContainer` so the scrollbar reflects the full list size.
166
+ * Calculates the logical height of the virtual item area. Header and footer are
167
+ * siblings of `innerContainer` and contribute to the scrollbar through their own
168
+ * layout boxes, so they must not be added here.
157
169
  * @returns {number} Total height in pixels.
158
170
  */
159
171
  const getTotalHeight = () => {
160
172
  const rows = getTotalRows();
161
- const headerH = headerElement?.offsetHeight || 0;
162
- const footerH = footerElement?.offsetHeight || 0;
163
- return headerH + (rows * (itemSize + gap) - gap) + footerH;
173
+ if (rows === 0) return 0;
174
+ return Math.max(0, rows * (itemSize + gap) - gap);
164
175
  };
165
176
 
166
177
  /** Applies the correct logical height to the inner container. */
@@ -178,15 +189,17 @@ export const ListView = (props) => {
178
189
  const renderVisibleItems = () => {
179
190
  if (!scrollContainer) return;
180
191
 
181
- const scrollTop = scrollContainer.scrollTop;
182
192
  const viewportH = scrollContainer.clientHeight;
183
193
  const itemTotalH = itemSize + gap;
194
+ // Convert the scroll position into ITEM space: rows start below the header, so
195
+ // the first rows are still on screen while `scrollTop` is inside the header.
196
+ const itemsTop = Math.max(0, scrollContainer.scrollTop - getHeaderHeight());
184
197
 
185
198
  // Clamp start/end rows to valid range, including the buffer zone
186
- let startRow = Math.max(0, Math.floor(scrollTop / itemTotalH) - bufferSize);
199
+ let startRow = Math.max(0, Math.floor(itemsTop / itemTotalH) - bufferSize);
187
200
  let endRow = Math.min(
188
201
  getTotalRows(),
189
- Math.ceil((scrollTop + viewportH) / itemTotalH) + bufferSize,
202
+ Math.ceil((itemsTop + viewportH) / itemTotalH) + bufferSize,
190
203
  );
191
204
 
192
205
  let start = startRow * cols;
@@ -247,24 +260,39 @@ export const ListView = (props) => {
247
260
  * Scroll event handler. Uses `requestAnimationFrame` throttling so the handler
248
261
  * runs at most once per frame even during rapid scroll events. Also checks
249
262
  * whether the user has scrolled close to the bottom and triggers `onEndReached`.
263
+ *
264
+ * The queued frame is tracked so unmounting can cancel it: an untracked frame
265
+ * rendered into a detached DOM and called `onEndReached` on a widget nobody owns
266
+ * anymore — which, for an infinite-scroll handler that appends data, is an
267
+ * unbounded fetch loop.
250
268
  */
251
269
  const handleScroll = () => {
252
- if (!ticking) {
253
- requestAnimationFrame(() => {
254
- renderVisibleItems();
255
- const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
256
- if (
257
- onEndReached &&
258
- _data.length &&
259
- scrollHeight - (scrollTop + clientHeight) <=
260
- clientHeight * onEndReachedThreshold
261
- ) {
262
- onEndReached();
263
- }
264
- ticking = false;
265
- });
266
- ticking = true;
267
- }
270
+ if (ticking || disposed) return;
271
+ ticking = true;
272
+ scrollRaf = requestAnimationFrame(() => {
273
+ scrollRaf = 0;
274
+ ticking = false;
275
+ if (disposed) return;
276
+ renderVisibleItems();
277
+ const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
278
+ if (
279
+ onEndReached &&
280
+ _data.length &&
281
+ scrollHeight - (scrollTop + clientHeight) <=
282
+ clientHeight * onEndReachedThreshold
283
+ ) {
284
+ onEndReached();
285
+ }
286
+ });
287
+ };
288
+
289
+ /**
290
+ * Recomputes the render window when the viewport itself changes size (window
291
+ * resize, a collapsing panel above the list). Without this, more rows becoming
292
+ * visible never triggered a render because no scroll event fired.
293
+ */
294
+ const handleViewportResize = () => {
295
+ if (!disposed) renderVisibleItems();
268
296
  };
269
297
 
270
298
  // ---- Header / footer / empty-state ----
@@ -407,8 +435,14 @@ export const ListView = (props) => {
407
435
  const updateData = (newData) => {
408
436
  _data = [...newData];
409
437
  itemCache.clear();
438
+ // The dataset changed but the viewport did not, so the previous window
439
+ // bounds would make `renderVisibleItems()` bail out and leave the OLD rows
440
+ // on screen for same-length data.
441
+ visibleStart = -1;
442
+ visibleEnd = -1;
410
443
  refreshUI();
411
- element.data = _data;
444
+ // Do NOT write `element.data` here: `data` is an accessor whose setter calls
445
+ // this very function, so assigning to it recursed until the stack overflowed.
412
446
  };
413
447
 
414
448
  /**
@@ -418,7 +452,8 @@ export const ListView = (props) => {
418
452
  */
419
453
  const scrollToIndex = (index, animated = true) => {
420
454
  if (index < 0 || index >= _data.length) return;
421
- const top = getItemTop(index);
455
+ // Back to SCROLL space: the header's height sits in front of the item area.
456
+ const top = getItemTop(index) + getHeaderHeight();
422
457
  scrollContainer.scrollTo({ top, behavior: animated ? "smooth" : "auto" });
423
458
  };
424
459
 
@@ -469,6 +504,8 @@ export const ListView = (props) => {
469
504
  });
470
505
 
471
506
  // ---- Lifecycle hooks ----
507
+ let resizeObserver = null;
508
+
472
509
  element.onMount(() => {
473
510
  renderHeader();
474
511
  renderFooter();
@@ -476,13 +513,21 @@ export const ListView = (props) => {
476
513
 
477
514
  // Defer the first actual render to the next animation frame so the header/footer
478
515
  // have already been measured and their heights are available.
479
- requestAnimationFrame(() => {
516
+ mountRaf = requestAnimationFrame(() => {
517
+ mountRaf = 0;
518
+ if (disposed) return;
480
519
  updateInnerHeight(); // Recalculate total height now that header/footer are in the DOM
481
520
  renderVisibleItems(); // Render the initially visible rows
482
521
  });
483
522
 
484
523
  renderEmpty();
485
524
  scrollContainer.addEventListener("scroll", handleScroll);
525
+ if (typeof ResizeObserver !== "undefined") {
526
+ resizeObserver = new ResizeObserver(handleViewportResize);
527
+ resizeObserver.observe(scrollContainer);
528
+ } else {
529
+ window.addEventListener("resize", handleViewportResize);
530
+ }
486
531
  if (onRefresh) setupPullToRefresh();
487
532
 
488
533
  // Inject the spinner keyframes once, globally, so pull-to-refresh has its animation
@@ -493,6 +538,11 @@ export const ListView = (props) => {
493
538
  });
494
539
 
495
540
  element.onUnmount(() => {
541
+ disposed = true;
542
+ if (scrollRaf) cancelAnimationFrame(scrollRaf);
543
+ if (mountRaf) cancelAnimationFrame(mountRaf);
544
+ if (resizeObserver) resizeObserver.disconnect();
545
+ else window.removeEventListener("resize", handleViewportResize);
496
546
  scrollContainer.removeEventListener("scroll", handleScroll);
497
547
  itemCache.clear();
498
548
  });
@@ -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,
@@ -72,7 +72,10 @@ export const Pagination = (props) => {
72
72
 
73
73
  const createPageButton = (page, isActive = false) => {
74
74
  const buttonVariant = isActive ? "filled" : variant;
75
- const buttonColor = isActive ? color : colors.textSecondary;
75
+ // FIX: antes `isActive ? color : colors.textSecondary` — pero bgColor también
76
+ // es `color`, así que texto y fondo eran el mismo tono → número invisible.
77
+ // Uso blanco para el activo (texto sobre fondo de color).
78
+ const buttonColor = isActive ? "#ffffff" : colors.textSecondary;
76
79
 
77
80
  return Button({
78
81
  text: String(page),
@@ -98,7 +101,7 @@ export const Pagination = (props) => {
98
101
  ...buttonBase,
99
102
  onPress: onClick,
100
103
  disabled: disabled || isDisabled,
101
- disableNativeEffects: true,
104
+ disableTransform: true,
102
105
  });
103
106
  };
104
107
 
@@ -192,4 +195,4 @@ export const Pagination = (props) => {
192
195
  return container;
193
196
  };
194
197
 
195
- export default Pagination;
198
+ export default Pagination;