flet-box 1.0.4 → 1.0.6

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 (147) hide show
  1. package/README.md +10 -0
  2. package/bin/cli.js +23 -7
  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 +3 -2
  120. package/src/animations/animate.js +180 -70
  121. package/src/animations/index.js +1 -0
  122. package/src/create-index.sh +1 -1
  123. package/src/index.d.ts +45 -22
  124. package/src/index.js +1 -0
  125. package/src/navigations/AdaptiveScaffold.js +11 -2
  126. package/src/navigations/BottomNavigation.js +10 -3
  127. package/src/navigations/Tabs.js +6 -2
  128. package/src/tools/useState.js +54 -63
  129. package/src/widget-factory/assignProps.js +23 -1
  130. package/src/widget-factory/processProps.js +10 -3
  131. package/src/widget-factory/reactiveValue.js +43 -18
  132. package/src/widget-factory/translateProps.js +777 -901
  133. package/src/widgets/AlertDialog.js +7 -5
  134. package/src/widgets/Checkbox.js +9 -7
  135. package/src/widgets/CircularBar.js +12 -1
  136. package/src/widgets/FloatingActionButton.js +4 -7
  137. package/src/widgets/Image.js +5 -7
  138. package/src/widgets/Input.js +92 -0
  139. package/src/widgets/ListView.js +73 -29
  140. package/src/widgets/Pagination.js +6 -3
  141. package/src/widgets/ProgressBar.js +16 -13
  142. package/src/widgets/Radio.js +6 -7
  143. package/src/widgets/Slider.js +23 -4
  144. package/src/widgets/Stack.js +36 -3
  145. package/src/widgets/Stepper.js +4 -3
  146. package/src/widgets/Switch.js +3 -4
  147. package/src/widgets/TreeView.js +0 -1
@@ -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;
@@ -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",
@@ -84,7 +83,10 @@ export const Checkbox = (props) => {
84
83
  // Hover effect
85
84
  checkbox.addEventListener("mouseenter", () => {
86
85
  if (!disabled && !internalChecked) {
87
- 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)`;
88
90
  }
89
91
  });
90
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
  });
@@ -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,18 +5,16 @@
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
+ // `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 || {};
13
12
  return WidgetFactory({
14
13
  tag: "img",
15
14
  widgetName: "Image",
16
- ...(fit !== undefined ? { objectFit: fit } : null),
17
15
  ...(circular ? { borderRadius: "50%" } : null),
18
16
  ...rest,
19
17
  });
20
18
  };
21
19
 
22
- export default Image;
20
+ export default Image;
@@ -26,6 +26,9 @@ import { composeUpdate } from "../utils/composeUpdate.js";
26
26
  * @property {string} [iconColor] - Color of the icons.
27
27
  * @property {number} [iconSize=20] - Size of the icons in pixels.
28
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.
29
32
  * @property {"none"|"letters"|"numbers"|"email"|"alphanumeric"|"safe"|"custom"} [validation="none"] - Validation mode applied on each keystroke and on blur.
30
33
  * @property {number} [maxLength] - Maximum number of characters allowed.
31
34
  * @property {boolean} [required=false] - Marks the field as required; validation fails on empty values.
@@ -39,6 +42,9 @@ import { composeUpdate } from "../utils/composeUpdate.js";
39
42
  * @property {Function} [onBlur] - Called when the input loses focus and final validation runs.
40
43
  */
41
44
 
45
+ /** Suffix for generated field ids, so two Inputs never share one. */
46
+ let inputIdSeed = 0;
47
+
42
48
  /**
43
49
  * A fully-featured text input widget with built-in validation, icons, and theme support.
44
50
  *
@@ -78,6 +84,9 @@ export const Input = (props) => {
78
84
  iconSize = 20,
79
85
  onIconPress,
80
86
 
87
+ clearable = false,
88
+ passwordToggle = false,
89
+
81
90
  validation = "none",
82
91
  maxLength,
83
92
  required = false,
@@ -90,6 +99,7 @@ export const Input = (props) => {
90
99
  onChange,
91
100
  onFocus,
92
101
  onBlur,
102
+ onEnter,
93
103
  ...rest
94
104
  } = props;
95
105
 
@@ -98,12 +108,22 @@ export const Input = (props) => {
98
108
  let isValid = true;
99
109
  let validationMessage = "";
100
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
+
101
120
  // DOM element references — populated during the build phase below
102
121
  let inputElement = null;
103
122
  let validationIcon = null;
104
123
  let errorMessageElement = null;
105
124
  let inputWrapper = null;
106
125
  let container = null;
126
+ let clearButton = null;
107
127
 
108
128
  // Whether the native input currently holds focus. Used to keep the focus
109
129
  // ring (a box-shadow) in sync with validation colors without ever changing
@@ -286,6 +306,7 @@ export const Input = (props) => {
286
306
  if (inputElement) {
287
307
  inputElement.value = filtered;
288
308
  }
309
+ if (clearButton) clearButton.style.display = filtered ? "flex" : "none";
289
310
 
290
311
  const result = validateValue(filtered);
291
312
  isValid = result.valid;
@@ -324,6 +345,7 @@ export const Input = (props) => {
324
345
  if (!text) return;
325
346
  labelEl = WidgetFactory({
326
347
  tag: "label",
348
+ for: fieldId,
327
349
  text,
328
350
  fontSize: 12,
329
351
  fontWeight: 500,
@@ -406,6 +428,7 @@ export const Input = (props) => {
406
428
  // - Accessibility (label association, screen readers)
407
429
  // - Browser autofill support
408
430
  inputElement = document.createElement("input");
431
+ inputElement.id = fieldId;
409
432
  inputElement.type = type;
410
433
  inputElement.placeholder = placeholder || "";
411
434
  inputElement.disabled = disabled || false;
@@ -468,6 +491,75 @@ export const Input = (props) => {
468
491
 
469
492
  inputWrapper.appendChild(inputElement);
470
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
+
471
563
  // --- Inline validation icon (✓ / ✗) shown inside the input on the right ---
472
564
  if (showValidationIcon && validation !== "none") {
473
565
  validationIcon = document.createElement("span");
@@ -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 ----
@@ -424,7 +452,8 @@ export const ListView = (props) => {
424
452
  */
425
453
  const scrollToIndex = (index, animated = true) => {
426
454
  if (index < 0 || index >= _data.length) return;
427
- 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();
428
457
  scrollContainer.scrollTo({ top, behavior: animated ? "smooth" : "auto" });
429
458
  };
430
459
 
@@ -475,6 +504,8 @@ export const ListView = (props) => {
475
504
  });
476
505
 
477
506
  // ---- Lifecycle hooks ----
507
+ let resizeObserver = null;
508
+
478
509
  element.onMount(() => {
479
510
  renderHeader();
480
511
  renderFooter();
@@ -482,13 +513,21 @@ export const ListView = (props) => {
482
513
 
483
514
  // Defer the first actual render to the next animation frame so the header/footer
484
515
  // have already been measured and their heights are available.
485
- requestAnimationFrame(() => {
516
+ mountRaf = requestAnimationFrame(() => {
517
+ mountRaf = 0;
518
+ if (disposed) return;
486
519
  updateInnerHeight(); // Recalculate total height now that header/footer are in the DOM
487
520
  renderVisibleItems(); // Render the initially visible rows
488
521
  });
489
522
 
490
523
  renderEmpty();
491
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
+ }
492
531
  if (onRefresh) setupPullToRefresh();
493
532
 
494
533
  // Inject the spinner keyframes once, globally, so pull-to-refresh has its animation
@@ -499,6 +538,11 @@ export const ListView = (props) => {
499
538
  });
500
539
 
501
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);
502
546
  scrollContainer.removeEventListener("scroll", handleScroll);
503
547
  itemCache.clear();
504
548
  });
@@ -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;
@@ -43,9 +43,9 @@ export const ProgressBar = (props) => {
43
43
  position: "relative",
44
44
  ...(striped || indeterminate
45
45
  ? {
46
- backgroundImage: `repeating-linear-gradient(45deg, rgba(255,255,255,0.15) 0px, rgba(255,255,255,0.15) 10px, transparent 10px, transparent 20px)`,
47
- backgroundSize: "28px 28px",
48
- }
46
+ backgroundImage: `repeating-linear-gradient(45deg, rgba(255,255,255,0.15) 0px, rgba(255,255,255,0.15) 10px, transparent 10px, transparent 20px)`,
47
+ backgroundSize: "28px 28px",
48
+ }
49
49
  : {}),
50
50
  });
51
51
 
@@ -115,9 +115,15 @@ export const ProgressBar = (props) => {
115
115
  );
116
116
  }
117
117
 
118
- // Value percentage
118
+ // Value percentage.
119
+ // FIX: guardo referencia directa al span del valor (`valueElRef`) en vez de
120
+ // buscarlo con `querySelector("span:last-child")` — la versión anterior
121
+ // podía escribir el porcentaje dentro del label (si el label estaba a la
122
+ // derecha, era el último span) y el check `valueEl !== label` nunca fallaba
123
+ // porque `label` era un string.
124
+ let valueElRef = null;
119
125
  if (showValue && !indeterminate) {
120
- const valueEl = WidgetFactory({
126
+ valueElRef = WidgetFactory({
121
127
  tag: "span",
122
128
  textContent: `${Math.round(percentage)}%`,
123
129
  fontSize: "12px",
@@ -129,9 +135,9 @@ export const ProgressBar = (props) => {
129
135
  });
130
136
 
131
137
  if (valuePosition === "left" || valuePosition === "top") {
132
- children.unshift(valueEl);
138
+ children.unshift(valueElRef);
133
139
  } else {
134
- children.push(valueEl);
140
+ children.push(valueElRef);
135
141
  }
136
142
  }
137
143
 
@@ -154,11 +160,8 @@ export const ProgressBar = (props) => {
154
160
  percentage = (progressValue / max) * 100;
155
161
  fill.style.width = `${percentage}%`;
156
162
 
157
- if (showValue) {
158
- const valueEl = container.querySelector("span:last-child");
159
- if (valueEl && valueEl !== label) {
160
- valueEl.textContent = `${Math.round(percentage)}%`;
161
- }
163
+ if (valueElRef) {
164
+ valueElRef.textContent = `${Math.round(percentage)}%`;
162
165
  }
163
166
  };
164
167
 
@@ -175,4 +178,4 @@ export const ProgressBar = (props) => {
175
178
  return container;
176
179
  };
177
180
 
178
- export default ProgressBar;
181
+ export default ProgressBar;
@@ -13,6 +13,7 @@ export const Radio = (props) => {
13
13
  disabled = false,
14
14
  size = 20,
15
15
  name,
16
+ activeColor = colors.primary,
16
17
  ...rest
17
18
  } = props;
18
19
 
@@ -26,10 +27,8 @@ export const Radio = (props) => {
26
27
 
27
28
  const updateUI = (newSelected) => {
28
29
  if (radioRef) {
29
- radioRef.style.backgroundColor = newSelected
30
- ? colors.primary
31
- : "transparent";
32
- radioRef.style.border = `2px solid ${newSelected ? colors.primary : colors.border}`;
30
+ radioRef.style.backgroundColor = newSelected ? activeColor : "transparent";
31
+ radioRef.style.border = `2px solid ${newSelected ? activeColor : colors.border}`;
33
32
  }
34
33
  if (dotRef) {
35
34
  dotRef.style.display = newSelected ? "block" : "none";
@@ -51,9 +50,9 @@ export const Radio = (props) => {
51
50
  widgetName: "Radio",
52
51
  width: size,
53
52
  height: size,
54
- border: `2px solid ${disabled ? colors.border : internalSelected ? colors.primary : colors.border}`,
53
+ border: `2px solid ${disabled ? colors.border : internalSelected ? activeColor : colors.border}`,
55
54
  borderRadius: "50%",
56
- backgroundColor: internalSelected ? colors.primary : "transparent",
55
+ backgroundColor: internalSelected ? activeColor : "transparent",
57
56
  display: "flex",
58
57
  alignItems: "center",
59
58
  justifyContent: "center",
@@ -96,7 +95,7 @@ export const Radio = (props) => {
96
95
  // Hover effect
97
96
  radio.addEventListener("mouseenter", () => {
98
97
  if (!disabled && !internalSelected) {
99
- radio.style.backgroundColor = colors.primary + "20";
98
+ radio.style.backgroundColor = `color-mix(in srgb, ${activeColor} 12%, transparent)`;
100
99
  }
101
100
  });
102
101