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.
- package/README.md +10 -0
- package/bin/cli.js +15 -0
- package/bin/commands/checkApi.js +761 -0
- package/bin/commands/create.js +77 -5
- package/bin/utils/dtsProps.js +191 -0
- package/bin/utils/moduleExports.js +165 -0
- package/bin/utils/neutralise.js +64 -0
- package/bin/utils/templates.js +366 -27
- package/docs/CONTRIBUTING.md +156 -0
- package/docs/README.md +68 -0
- package/docs/arquitectura.md +164 -0
- package/docs/cli/README.md +542 -0
- package/docs/guides/README.md +33 -0
- package/docs/guides/app-templates.md +197 -0
- package/docs/guides/compatibility.md +95 -0
- package/docs/guides/embedding.md +284 -0
- package/docs/guides/first-app.md +208 -0
- package/docs/guides/frontend-services.md +171 -0
- package/docs/guides/minification-and-protection.md +260 -0
- package/docs/guides/mobile-and-platforms.md +439 -0
- package/docs/guides/online-updates.md +196 -0
- package/docs/guides/router.md +282 -0
- package/docs/guides/state.md +283 -0
- package/docs/guides/syntax-philosophy.md +150 -0
- package/docs/guides/utilities.md +241 -0
- package/docs/guides/widget-structure.md +163 -0
- package/docs/server/README.md +68 -0
- package/docs/server/START_HERE.md +121 -0
- package/docs/server/api.md +225 -0
- package/docs/server/data-services.md +300 -0
- package/docs/server/deployment.md +113 -0
- package/docs/server/security.md +196 -0
- package/docs/services/README.md +79 -0
- package/docs/services/RamStore.md +84 -0
- package/docs/services/Session.md +84 -0
- package/docs/services/Storage.md +88 -0
- package/docs/services/http.md +86 -0
- package/docs/tools/README.md +138 -0
- package/docs/tools/animation.md +87 -0
- package/docs/tools/async-ids-log.md +80 -0
- package/docs/tools/device.md +132 -0
- package/docs/tools/introspection.md +67 -0
- package/docs/tools/lists.md +96 -0
- package/docs/tools/markdown.md +79 -0
- package/docs/tools/reactivity.md +288 -0
- package/docs/tools/state.md +103 -0
- package/docs/tools/styling.md +92 -0
- package/docs/tools/text-and-time.md +89 -0
- package/docs/tools/theme.md +102 -0
- package/docs/tools/validation.md +75 -0
- package/docs/tools/visual-effects.md +84 -0
- package/docs/widget/Accordion.md +144 -0
- package/docs/widget/AdMob.md +144 -0
- package/docs/widget/AdSense.md +133 -0
- package/docs/widget/AdaptiveScaffold.md +105 -0
- package/docs/widget/AlertDialog.md +103 -0
- package/docs/widget/AnimatedBox.md +130 -0
- package/docs/widget/AnimatedText.md +123 -0
- package/docs/widget/AppBar.md +118 -0
- package/docs/widget/Audio.md +135 -0
- package/docs/widget/Avatar.md +119 -0
- package/docs/widget/Badge.md +128 -0
- package/docs/widget/BottomNavigation.md +116 -0
- package/docs/widget/BottomSheet.md +125 -0
- package/docs/widget/Button.md +137 -0
- package/docs/widget/COMMON_PROPS.md +150 -0
- package/docs/widget/Card.md +125 -0
- package/docs/widget/Carousel.md +151 -0
- package/docs/widget/Chart.md +151 -0
- package/docs/widget/Checkbox.md +118 -0
- package/docs/widget/Chip.md +126 -0
- package/docs/widget/CircularBar.md +168 -0
- package/docs/widget/CircularChart.md +164 -0
- package/docs/widget/CodeViewer.md +130 -0
- package/docs/widget/CollapsibleSideBar.md +112 -0
- package/docs/widget/Column.md +129 -0
- package/docs/widget/Container.md +136 -0
- package/docs/widget/DataTable.md +150 -0
- package/docs/widget/Divider.md +109 -0
- package/docs/widget/DraggBox.md +129 -0
- package/docs/widget/Drawer.md +135 -0
- package/docs/widget/DrawerItem.md +98 -0
- package/docs/widget/Dropdown.md +139 -0
- package/docs/widget/DroppBox.md +152 -0
- package/docs/widget/FloatingActionButton.md +120 -0
- package/docs/widget/GridView.md +140 -0
- package/docs/widget/Icon.md +117 -0
- package/docs/widget/Image.md +125 -0
- package/docs/widget/Input.md +151 -0
- package/docs/widget/Inspector.md +92 -0
- package/docs/widget/InstallButton.md +128 -0
- package/docs/widget/ListTile.md +132 -0
- package/docs/widget/ListView.md +138 -0
- package/docs/widget/Markdown.md +175 -0
- package/docs/widget/MatrixRain.md +122 -0
- package/docs/widget/Modal.md +129 -0
- package/docs/widget/Pagination.md +135 -0
- package/docs/widget/ParallaxBox.md +128 -0
- package/docs/widget/ProgressBar.md +130 -0
- package/docs/widget/QRCode.md +117 -0
- package/docs/widget/README.md +188 -0
- package/docs/widget/Radio.md +123 -0
- package/docs/widget/Rating.md +131 -0
- package/docs/widget/Row.md +131 -0
- package/docs/widget/START_HERE.md +146 -0
- package/docs/widget/Scaffold.md +152 -0
- package/docs/widget/Skeleton.md +130 -0
- package/docs/widget/Slider.md +146 -0
- package/docs/widget/SnackBar.md +102 -0
- package/docs/widget/Stack.md +128 -0
- package/docs/widget/Stepper.md +145 -0
- package/docs/widget/Switch.md +118 -0
- package/docs/widget/Tabs.md +124 -0
- package/docs/widget/Text.md +133 -0
- package/docs/widget/Tooltip.md +145 -0
- package/docs/widget/TreeView.md +176 -0
- package/docs/widget/Video.md +127 -0
- package/docs/widget/_template.md +61 -0
- package/package.json +4 -2
- package/src/animations/AnimatedBox.js +32 -1
- package/src/animations/AnimatedText.js +7 -3
- package/src/animations/MatrixRain.js +37 -12
- package/src/animations/ParallaxBox.js +16 -3
- package/src/animations/animate.js +187 -58
- package/src/animations/index.js +1 -0
- package/src/core/runApp.js +13 -1
- package/src/create-index.sh +1 -1
- package/src/index.d.ts +227 -91
- package/src/index.js +1 -0
- package/src/navigations/AdaptiveScaffold.js +11 -2
- package/src/navigations/AppBar.js +8 -2
- package/src/navigations/BottomNavigation.js +24 -5
- package/src/navigations/DrawerItem.js +16 -3
- package/src/navigations/Scaffold.js +8 -1
- package/src/navigations/Tabs.js +40 -8
- package/src/tools/dimensions.js +11 -3
- package/src/tools/useState.js +139 -101
- package/src/utils/getWidgetProps.js +5 -3
- package/src/widget-factory/assignProps.js +30 -11
- package/src/widget-factory/processProps.js +17 -5
- package/src/widget-factory/reactiveValue.js +172 -0
- package/src/widget-factory/translateProps.js +777 -901
- package/src/widget-factory/warnings.js +15 -0
- package/src/widget-factory/widgetFactory.js +27 -0
- package/src/widgets/Accordion.js +6 -5
- package/src/widgets/AlertDialog.js +7 -5
- package/src/widgets/Audio.js +30 -8
- package/src/widgets/BottomSheet.js +24 -1
- package/src/widgets/Chart.js +4 -1
- package/src/widgets/Checkbox.js +16 -8
- package/src/widgets/CircularBar.js +12 -1
- package/src/widgets/CircularChart.js +4 -1
- package/src/widgets/CodeViewer.js +26 -8
- package/src/widgets/DataTable.js +11 -2
- package/src/widgets/Dropdown.js +5 -1
- package/src/widgets/FloatingActionButton.js +4 -7
- package/src/widgets/Image.js +7 -2
- package/src/widgets/Input.js +119 -6
- package/src/widgets/Inspector.js +16 -10
- package/src/widgets/ListView.js +80 -30
- package/src/widgets/Markdown.js +53 -8
- package/src/widgets/Modal.js +24 -1
- package/src/widgets/Pagination.js +6 -3
- package/src/widgets/ProgressBar.js +16 -13
- package/src/widgets/QRCode.js +9 -2
- package/src/widgets/Radio.js +11 -8
- package/src/widgets/Slider.js +26 -4
- package/src/widgets/SnackBar.js +20 -2
- package/src/widgets/Stack.js +36 -3
- package/src/widgets/Stepper.js +4 -3
- package/src/widgets/Switch.js +8 -5
- package/src/widgets/TreeView.js +3 -2
package/src/widgets/Input.js
CHANGED
|
@@ -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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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.
|
|
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");
|
package/src/widgets/Inspector.js
CHANGED
|
@@ -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 (
|
|
175
|
-
childStr = `\n${nextSpaces}child: ${Inspector(
|
|
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 (
|
|
181
|
-
const childrenItems =
|
|
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 + " ");
|
package/src/widgets/ListView.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
151
|
-
return headerHeight + row * (itemSize + gap);
|
|
162
|
+
return row * (itemSize + gap);
|
|
152
163
|
};
|
|
153
164
|
|
|
154
165
|
/**
|
|
155
|
-
* Calculates the
|
|
156
|
-
*
|
|
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
|
-
|
|
162
|
-
|
|
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(
|
|
199
|
+
let startRow = Math.max(0, Math.floor(itemsTop / itemTotalH) - bufferSize);
|
|
187
200
|
let endRow = Math.min(
|
|
188
201
|
getTotalRows(),
|
|
189
|
-
Math.ceil((
|
|
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 (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/src/widgets/Markdown.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
package/src/widgets/Modal.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|