flet-box 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ota/loader.js +6 -1
- package/bin/utils/templates.js +6 -2
- package/package.json +3 -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 +22 -3
- package/src/core/runApp.js +13 -1
- package/src/create-index.sh +2 -2
- package/src/index.d.ts +193 -79
- package/src/index.js +1 -0
- package/src/navigations/AppBar.js +17 -4
- package/src/navigations/BottomNavigation.js +14 -2
- package/src/navigations/DrawerItem.js +16 -3
- package/src/navigations/Router.js +125 -6
- package/src/navigations/Scaffold.js +13 -3
- package/src/navigations/Tabs.js +34 -6
- package/src/navigations/index.js +2 -0
- package/src/tools/dimensions.js +11 -3
- package/src/tools/useState.js +115 -68
- package/src/utils/getWidgetProps.js +5 -3
- package/src/widget-factory/assignProps.js +7 -10
- package/src/widget-factory/processProps.js +7 -2
- package/src/widget-factory/reactiveValue.js +147 -0
- 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/Audio.js +30 -8
- package/src/widgets/BottomSheet.js +24 -1
- package/src/widgets/Chart.js +4 -1
- package/src/widgets/Checkbox.js +7 -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/Image.js +8 -1
- package/src/widgets/Input.js +27 -6
- package/src/widgets/Inspector.js +16 -10
- package/src/widgets/ListView.js +7 -1
- package/src/widgets/Markdown.js +53 -8
- package/src/widgets/Modal.js +24 -1
- package/src/widgets/QRCode.js +9 -2
- package/src/widgets/Radio.js +5 -1
- package/src/widgets/Slider.js +3 -0
- package/src/widgets/SnackBar.js +20 -2
- package/src/widgets/Switch.js +5 -1
- package/src/widgets/TreeView.js +3 -1
|
@@ -58,6 +58,8 @@ export const BottomSheet = (props) => {
|
|
|
58
58
|
|
|
59
59
|
let isOpen = false;
|
|
60
60
|
let overlay = null;
|
|
61
|
+
// Pending "hide after the close animation" timer, so it can be cancelled.
|
|
62
|
+
let closeTimer = null;
|
|
61
63
|
let sheetContainer = null;
|
|
62
64
|
let startY = 0;
|
|
63
65
|
let currentY = 0;
|
|
@@ -190,6 +192,18 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
|
|
|
190
192
|
overlay.appendChild(sheetContainer);
|
|
191
193
|
document.body.appendChild(overlay);
|
|
192
194
|
|
|
195
|
+
// The overlay lives in `document.body` and can be removed without `close()`
|
|
196
|
+
// ever running (state rebuild, route change, plain removeChild of the host).
|
|
197
|
+
// `_cleanup` is not called on that path, so release the document listener and
|
|
198
|
+
// any pending animation timer through the lifecycle hook too.
|
|
199
|
+
overlay.onUnmount(() => {
|
|
200
|
+
if (closeTimer) {
|
|
201
|
+
clearTimeout(closeTimer);
|
|
202
|
+
closeTimer = null;
|
|
203
|
+
}
|
|
204
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
205
|
+
});
|
|
206
|
+
|
|
193
207
|
// Drag handlers
|
|
194
208
|
const onTouchStart = (e) => {
|
|
195
209
|
// ✅ Prevent the drag event from propagating to the overlay
|
|
@@ -229,6 +243,11 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
|
|
|
229
243
|
const open = () => {
|
|
230
244
|
if (isOpen) return;
|
|
231
245
|
isOpen = true;
|
|
246
|
+
// Cancel any pending hide from a close that is still animating out.
|
|
247
|
+
if (closeTimer) {
|
|
248
|
+
clearTimeout(closeTimer);
|
|
249
|
+
closeTimer = null;
|
|
250
|
+
}
|
|
232
251
|
overlay.style.visibility = "visible";
|
|
233
252
|
overlay.style.opacity = "1";
|
|
234
253
|
sheetContainer.style.transform = "translateY(0)";
|
|
@@ -250,7 +269,11 @@ onclick: (e) => e.stopPropagation(), // ✅ Prevents the click from reaching the
|
|
|
250
269
|
isOpen = false;
|
|
251
270
|
sheetContainer.style.transform = "translateY(100%)";
|
|
252
271
|
overlay.style.opacity = "0";
|
|
253
|
-
|
|
272
|
+
// Keep the id: `open()` clears it, otherwise reopening within the animation
|
|
273
|
+
// window lets a stale timer hide an OPEN sheet (invisible but isOpen true).
|
|
274
|
+
if (closeTimer) clearTimeout(closeTimer);
|
|
275
|
+
closeTimer = setTimeout(() => {
|
|
276
|
+
closeTimer = null;
|
|
254
277
|
overlay.style.visibility = "hidden";
|
|
255
278
|
}, animationDuration);
|
|
256
279
|
if (onClose) onClose();
|
package/src/widgets/Chart.js
CHANGED
|
@@ -134,7 +134,10 @@ export const Chart = (props) => {
|
|
|
134
134
|
const paddingRange = range * 0.1;
|
|
135
135
|
return {
|
|
136
136
|
maxValue: maxVal + paddingRange,
|
|
137
|
-
|
|
137
|
+
// Do NOT clamp to 0. With all-negative data that inverts the range
|
|
138
|
+
// (min > max) and every point is drawn far outside the canvas, so the
|
|
139
|
+
// chart looks empty and the axis labels run from 0 to a negative max.
|
|
140
|
+
minValue: minVal - paddingRange,
|
|
138
141
|
};
|
|
139
142
|
};
|
|
140
143
|
|
package/src/widgets/Checkbox.js
CHANGED
|
@@ -67,13 +67,19 @@ export const Checkbox = (props) => {
|
|
|
67
67
|
|
|
68
68
|
// Handle click
|
|
69
69
|
if (!disabled) {
|
|
70
|
-
|
|
70
|
+
const handleClick = (e) => {
|
|
71
71
|
e.stopPropagation();
|
|
72
72
|
const newChecked = !internalChecked;
|
|
73
73
|
internalChecked = newChecked;
|
|
74
74
|
updateUI(newChecked);
|
|
75
75
|
if (onPress) onPress(newChecked);
|
|
76
76
|
};
|
|
77
|
+
// Route the handler through the factory's props instead of assigning
|
|
78
|
+
// `onclick` directly. `assignProps` clears `onclick` on every update and only
|
|
79
|
+
// re-applies handlers it can see in the merged props, so a direct assignment
|
|
80
|
+
// died on the first `update()` — and every reactive setter (opacity, bgColor,
|
|
81
|
+
// padding…) calls update — leaving the control unable to toggle ever again.
|
|
82
|
+
checkbox.update({ onPress: handleClick });
|
|
77
83
|
|
|
78
84
|
// Hover effect
|
|
79
85
|
checkbox.addEventListener("mouseenter", () => {
|
|
@@ -335,7 +335,10 @@ export const CircularChart = (props) => {
|
|
|
335
335
|
if (!ctx || !canvas) return;
|
|
336
336
|
if (!startTime) {
|
|
337
337
|
startTime = timestamp;
|
|
338
|
-
|
|
338
|
+
// Keep the id. The previous frame's id is already spent, so cleanup would
|
|
339
|
+
// cancel nothing and the pending frame kept re-arming forever on a detached
|
|
340
|
+
// canvas — and a re-`startAnimation()` could run two loops at once.
|
|
341
|
+
animationId = requestAnimationFrame(updateAnimation);
|
|
339
342
|
return;
|
|
340
343
|
}
|
|
341
344
|
const elapsed = timestamp - startTime;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { WidgetFactory } from "../widget-factory/index.js";
|
|
6
6
|
import { colors } from "../utils/themes.js";
|
|
7
|
+
import { composeUpdate } from "../utils/composeUpdate.js";
|
|
7
8
|
import { generateHighlightedHtml } from "../utils/syntaxHighlight.js";
|
|
8
9
|
|
|
9
10
|
export const CodeViewer = (props) => {
|
|
@@ -81,8 +82,14 @@ code = "",
|
|
|
81
82
|
...rest,
|
|
82
83
|
});
|
|
83
84
|
|
|
84
|
-
// Header
|
|
85
|
-
|
|
85
|
+
// Header. Built through a helper so a later `title` update can create one on a
|
|
86
|
+
// widget that was constructed without it.
|
|
87
|
+
const ensureHeader = (text) => {
|
|
88
|
+
if (!showHeader || !text) return null;
|
|
89
|
+
if (headerRef) {
|
|
90
|
+
headerRef.textContent = text;
|
|
91
|
+
return headerRef;
|
|
92
|
+
}
|
|
86
93
|
headerRef = WidgetFactory({
|
|
87
94
|
tag: "div",
|
|
88
95
|
padding: "8px 12px",
|
|
@@ -91,10 +98,12 @@ code = "",
|
|
|
91
98
|
color: "#fff",
|
|
92
99
|
fontSize: "14px",
|
|
93
100
|
fontWeight: "bold",
|
|
94
|
-
textContent:
|
|
101
|
+
textContent: text,
|
|
95
102
|
});
|
|
96
|
-
container.
|
|
97
|
-
|
|
103
|
+
container.insertBefore(headerRef, container.firstChild);
|
|
104
|
+
return headerRef;
|
|
105
|
+
};
|
|
106
|
+
ensureHeader(currentTitle);
|
|
98
107
|
|
|
99
108
|
// Scroll wrapper
|
|
100
109
|
scrollWrapperRef = WidgetFactory({
|
|
@@ -209,11 +218,20 @@ code = "",
|
|
|
209
218
|
|
|
210
219
|
container.updateTitle = (newTitle) => {
|
|
211
220
|
currentTitle = newTitle;
|
|
212
|
-
|
|
213
|
-
headerRef.textContent = currentTitle;
|
|
214
|
-
}
|
|
221
|
+
ensureHeader(currentTitle);
|
|
215
222
|
};
|
|
216
223
|
|
|
224
|
+
// `title` is a reactive prop mapped to `textContent` in the prop database, so
|
|
225
|
+
// letting the factory see it would replace the header and the whole code
|
|
226
|
+
// wrapper with a bare text node. Own it and update the header instead.
|
|
227
|
+
composeUpdate(container, ["title"], (props) => {
|
|
228
|
+
if (props.title === undefined) return;
|
|
229
|
+
container.updateTitle(props.title);
|
|
230
|
+
// `makeReactive` rewrites the value into `_originalProps` on every setter
|
|
231
|
+
// call; leaving it there means the next factory update wipes the widget.
|
|
232
|
+
delete container._originalProps.title;
|
|
233
|
+
});
|
|
234
|
+
|
|
217
235
|
container.scrollTo = (x, y) => {
|
|
218
236
|
if (scrollWrapperRef) {
|
|
219
237
|
scrollWrapperRef.scrollLeft = x;
|
package/src/widgets/DataTable.js
CHANGED
|
@@ -83,7 +83,9 @@ export const DataTable = (props) => {
|
|
|
83
83
|
table.appendChild(thead);
|
|
84
84
|
|
|
85
85
|
// ========== BODY ==========
|
|
86
|
-
const
|
|
86
|
+
// `let`, not `const`: `updateData` replaces the body and must re-point here,
|
|
87
|
+
// otherwise the second call tries to replace an already-detached node.
|
|
88
|
+
let tbody = document.createElement("tbody");
|
|
87
89
|
|
|
88
90
|
rows.forEach((row, index) => {
|
|
89
91
|
const tr = document.createElement("tr");
|
|
@@ -152,8 +154,12 @@ export const DataTable = (props) => {
|
|
|
152
154
|
|
|
153
155
|
// Update table data
|
|
154
156
|
container.updateData = (newRows) => {
|
|
157
|
+
// Snapshot BEFORE clearing. `updateColumns` calls this with `rows` itself as
|
|
158
|
+
// the argument, so clearing first and then spreading produced an empty body
|
|
159
|
+
// and every row silently disappeared.
|
|
160
|
+
const nextRows = [...newRows];
|
|
155
161
|
rows.length = 0;
|
|
156
|
-
rows.push(...
|
|
162
|
+
rows.push(...nextRows);
|
|
157
163
|
|
|
158
164
|
// Rebuild body
|
|
159
165
|
const newTbody = document.createElement("tbody");
|
|
@@ -213,6 +219,9 @@ export const DataTable = (props) => {
|
|
|
213
219
|
});
|
|
214
220
|
|
|
215
221
|
table.replaceChild(newTbody, tbody);
|
|
222
|
+
// Re-point at the live node, otherwise the next call replaces an already
|
|
223
|
+
// detached node and throws NotFoundError.
|
|
224
|
+
tbody = newTbody;
|
|
216
225
|
};
|
|
217
226
|
|
|
218
227
|
// Update table columns
|
package/src/widgets/Dropdown.js
CHANGED
|
@@ -243,7 +243,11 @@ export const Dropdown = (props) => {
|
|
|
243
243
|
if (outsideClickListener)
|
|
244
244
|
document.removeEventListener("click", outsideClickListener);
|
|
245
245
|
if (resizeListener) window.removeEventListener("resize", resizeListener);
|
|
246
|
-
|
|
246
|
+
// The capture flag MUST match the one used when adding (see the `scroll`
|
|
247
|
+
// registration below), otherwise the listener is never removed: every
|
|
248
|
+
// open/close cycle leaked one window listener holding the whole closure.
|
|
249
|
+
if (scrollListener)
|
|
250
|
+
window.removeEventListener("scroll", scrollListener, true);
|
|
247
251
|
outsideClickListener = resizeListener = scrollListener = null;
|
|
248
252
|
};
|
|
249
253
|
|
package/src/widgets/Image.js
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
import { WidgetFactory } from "../widget-factory/index.js";
|
|
6
6
|
|
|
7
7
|
export const Image = (props) => {
|
|
8
|
+
// `fit` and `circular` are documented Image props with no entry in the prop
|
|
9
|
+
// database, so forwarding them raw only produced an "unknown prop" warning
|
|
10
|
+
// while images stayed stretched (`object-fit: fill`) and square. Map them onto
|
|
11
|
+
// real CSS. `...rest` comes last so an explicit `borderRadius` still wins.
|
|
12
|
+
const { fit, circular, ...rest } = props || {};
|
|
8
13
|
return WidgetFactory({
|
|
9
14
|
tag: "img",
|
|
10
15
|
widgetName: "Image",
|
|
11
|
-
...
|
|
16
|
+
...(fit !== undefined ? { objectFit: fit } : null),
|
|
17
|
+
...(circular ? { borderRadius: "50%" } : null),
|
|
18
|
+
...rest,
|
|
12
19
|
});
|
|
13
20
|
};
|
|
14
21
|
|
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
|
|
@@ -314,11 +315,16 @@ export const Input = (props) => {
|
|
|
314
315
|
});
|
|
315
316
|
|
|
316
317
|
// --- Optional label rendered above the input ---
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
let labelEl = null;
|
|
319
|
+
const renderLabel = (text) => {
|
|
320
|
+
if (labelEl) {
|
|
321
|
+
labelEl.textContent = text;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!text) return;
|
|
325
|
+
labelEl = WidgetFactory({
|
|
320
326
|
tag: "label",
|
|
321
|
-
text
|
|
327
|
+
text,
|
|
322
328
|
fontSize: 12,
|
|
323
329
|
fontWeight: 500,
|
|
324
330
|
color: error ? colors.danger : colors.textSecondary,
|
|
@@ -329,8 +335,23 @@ export const Input = (props) => {
|
|
|
329
335
|
labelEl.addEventListener("click", () => {
|
|
330
336
|
inputElement?.focus();
|
|
331
337
|
});
|
|
332
|
-
container.
|
|
333
|
-
}
|
|
338
|
+
container.insertBefore(labelEl, container.firstChild);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
if (label) renderLabel(label + (required ? " *" : ""));
|
|
342
|
+
|
|
343
|
+
// `label` is registered as a REACTIVE_PROP that maps to `textContent`, so if the
|
|
344
|
+
// factory ever sees it, `container.textContent = "…"` replaces this whole
|
|
345
|
+
// composite widget (label + wrapper + native <input> + icons) with a bare text
|
|
346
|
+
// node. Own the key and update the label element instead.
|
|
347
|
+
composeUpdate(container, ["label"], (props) => {
|
|
348
|
+
if (props.label === undefined) return;
|
|
349
|
+
renderLabel(props.label ? String(props.label) + (required ? " *" : "") : "");
|
|
350
|
+
// `makeReactive` writes the value back into `_originalProps` on every setter
|
|
351
|
+
// call; leaving it there means the next factory update merges it, maps it to
|
|
352
|
+
// `textContent` and wipes the widget anyway.
|
|
353
|
+
delete container._originalProps.label;
|
|
354
|
+
});
|
|
334
355
|
|
|
335
356
|
// --- Wrapper — contains icons + native input, owns the border/fill style ---
|
|
336
357
|
let wrapperStyles = {
|
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
|
@@ -407,8 +407,14 @@ export const ListView = (props) => {
|
|
|
407
407
|
const updateData = (newData) => {
|
|
408
408
|
_data = [...newData];
|
|
409
409
|
itemCache.clear();
|
|
410
|
+
// The dataset changed but the viewport did not, so the previous window
|
|
411
|
+
// bounds would make `renderVisibleItems()` bail out and leave the OLD rows
|
|
412
|
+
// on screen for same-length data.
|
|
413
|
+
visibleStart = -1;
|
|
414
|
+
visibleEnd = -1;
|
|
410
415
|
refreshUI();
|
|
411
|
-
element.data
|
|
416
|
+
// Do NOT write `element.data` here: `data` is an accessor whose setter calls
|
|
417
|
+
// this very function, so assigning to it recursed until the stack overflowed.
|
|
412
418
|
};
|
|
413
419
|
|
|
414
420
|
/**
|
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,
|
package/src/widgets/QRCode.js
CHANGED
|
@@ -116,7 +116,9 @@ function buildQRMatrix(text, ecLevel = "M") {
|
|
|
116
116
|
for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1);
|
|
117
117
|
};
|
|
118
118
|
pushBits(0b0100, 4); // mode indicator: byte
|
|
119
|
-
|
|
119
|
+
// Character count: 8 bits for versions 1-9, 16 bits for versions 10-40
|
|
120
|
+
// (ISO/IEC 18004). Emitting 8 bits at version 10 misaligns every later bit.
|
|
121
|
+
pushBits(bytes.length, version < 10 ? 8 : 16);
|
|
120
122
|
for (const b of bytes) pushBits(b, 8);
|
|
121
123
|
// Terminator
|
|
122
124
|
for (let i = 0; i < 4 && bits.length < dataCodewords * 8; i++) bits.push(0);
|
|
@@ -161,7 +163,12 @@ function buildQRMatrix(text, ecLevel = "M") {
|
|
|
161
163
|
|
|
162
164
|
// 7. Build final bit sequence
|
|
163
165
|
const finalBits = [];
|
|
164
|
-
|
|
166
|
+
// NOTE: `pushBits` writes into `bits` (the pre-interleave stream), so it must
|
|
167
|
+
// not be used here. Using it left `finalBits` empty, and the placement pass
|
|
168
|
+
// then wrote every data module as 0 — the symbol carried no payload at all.
|
|
169
|
+
for (const cw of finalCW) {
|
|
170
|
+
for (let i = 7; i >= 0; i--) finalBits.push((cw >> i) & 1);
|
|
171
|
+
}
|
|
165
172
|
// Remainder bits (version-dependent, versions 2–6 need 7)
|
|
166
173
|
const remainderBitsCount = version >= 2 && version <= 6 ? 7 : 0;
|
|
167
174
|
for (let i = 0; i < remainderBitsCount; i++) finalBits.push(0);
|
package/src/widgets/Radio.js
CHANGED
|
@@ -67,7 +67,7 @@ export const Radio = (props) => {
|
|
|
67
67
|
|
|
68
68
|
// Handle click
|
|
69
69
|
if (!disabled) {
|
|
70
|
-
|
|
70
|
+
const handleClick = (e) => {
|
|
71
71
|
e.stopPropagation();
|
|
72
72
|
if (internalSelected) return;
|
|
73
73
|
|
|
@@ -88,6 +88,10 @@ export const Radio = (props) => {
|
|
|
88
88
|
|
|
89
89
|
if (onPress) onPress(true);
|
|
90
90
|
};
|
|
91
|
+
// Routed through the factory props: `assignProps` clears `onclick` on every
|
|
92
|
+
// update and only re-applies handlers present in the merged props, so a
|
|
93
|
+
// direct assignment died on the first `update()` or reactive setter.
|
|
94
|
+
radio.update({ onPress: handleClick });
|
|
91
95
|
|
|
92
96
|
// Hover effect
|
|
93
97
|
radio.addEventListener("mouseenter", () => {
|
package/src/widgets/Slider.js
CHANGED
|
@@ -299,6 +299,9 @@ export const Slider = (props) => {
|
|
|
299
299
|
|
|
300
300
|
function generateDefaultMarks() {
|
|
301
301
|
const stepMark = (currentMax - currentMin) / 4;
|
|
302
|
+
// With `min === max` (or non-numeric bounds) stepMark is 0/NaN, so
|
|
303
|
+
// `i += stepMark` never advances and the loop hangs the tab forever.
|
|
304
|
+
if (!(stepMark > 0)) return [currentMin];
|
|
302
305
|
const marksList = [];
|
|
303
306
|
for (let i = currentMin; i <= currentMax + 0.1; i += stepMark) {
|
|
304
307
|
marksList.push(Math.round(i));
|
package/src/widgets/SnackBar.js
CHANGED
|
@@ -124,9 +124,18 @@ export function SnackBar(options) {
|
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
let timeoutId = null;
|
|
127
|
+
let rafId = null;
|
|
128
|
+
// `close()` is final: it must fire `onClose` exactly once and must win any
|
|
129
|
+
// race against the pending `show()` frame, otherwise closing a toast right
|
|
130
|
+
// after creating it would flash it back to opacity 1 for a full animation.
|
|
131
|
+
let closed = false;
|
|
127
132
|
|
|
128
133
|
const show = () => {
|
|
129
|
-
|
|
134
|
+
if (closed) return;
|
|
135
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
136
|
+
rafId = requestAnimationFrame(() => {
|
|
137
|
+
rafId = null;
|
|
138
|
+
if (closed) return;
|
|
130
139
|
snackbar.style.opacity = "1";
|
|
131
140
|
snackbar.style.transform = "translateY(0)";
|
|
132
141
|
});
|
|
@@ -134,7 +143,16 @@ export function SnackBar(options) {
|
|
|
134
143
|
};
|
|
135
144
|
|
|
136
145
|
const close = () => {
|
|
137
|
-
if (
|
|
146
|
+
if (closed) return;
|
|
147
|
+
closed = true;
|
|
148
|
+
if (timeoutId) {
|
|
149
|
+
clearTimeout(timeoutId);
|
|
150
|
+
timeoutId = null;
|
|
151
|
+
}
|
|
152
|
+
if (rafId !== null) {
|
|
153
|
+
cancelAnimationFrame(rafId);
|
|
154
|
+
rafId = null;
|
|
155
|
+
}
|
|
138
156
|
snackbar.style.opacity = "0";
|
|
139
157
|
snackbar.style.transform =
|
|
140
158
|
position === "bottom"
|
package/src/widgets/Switch.js
CHANGED
|
@@ -79,13 +79,17 @@ export const Switch = (props) => {
|
|
|
79
79
|
|
|
80
80
|
// Handle click
|
|
81
81
|
if (!disabled) {
|
|
82
|
-
|
|
82
|
+
const handleClick = (e) => {
|
|
83
83
|
e.stopPropagation();
|
|
84
84
|
const newValue = !internalValue;
|
|
85
85
|
internalValue = newValue;
|
|
86
86
|
updateUI(newValue);
|
|
87
87
|
if (onPress) onPress(newValue);
|
|
88
88
|
};
|
|
89
|
+
// Routed through the factory props: `assignProps` clears `onclick` on every
|
|
90
|
+
// update and only re-applies handlers present in the merged props, so a
|
|
91
|
+
// direct assignment died on the first `update()` or reactive setter.
|
|
92
|
+
switchContainer.update({ onPress: handleClick });
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
// Public methods
|
package/src/widgets/TreeView.js
CHANGED
|
@@ -280,7 +280,9 @@ export const TreeView = (props) => {
|
|
|
280
280
|
|
|
281
281
|
// ========== PUBLIC METHODS ==========
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
// NOTE: no `selectedId = null` default. The guard below tests `!== undefined`,
|
|
284
|
+
// so a `null` default made it always true and every call wiped the selection.
|
|
285
|
+
container.updateNodes = (newNodes, selectedId) => {
|
|
284
286
|
nodes.length = 0;
|
|
285
287
|
nodes.push(...newNodes);
|
|
286
288
|
if (selectedId !== undefined) currentSelectedId = selectedId;
|