flet-box 1.0.5 → 1.0.7

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 (94) hide show
  1. package/README.md +28 -1
  2. package/bin/cli.js +85 -18
  3. package/bin/commands/auditFix.js +682 -0
  4. package/bin/commands/checkApi.js +7 -5
  5. package/bin/commands/create.js +40 -10
  6. package/bin/commands/createBundle.js +54 -1
  7. package/bin/commands/images.js +349 -0
  8. package/bin/commands/pruneAssets.js +170 -0
  9. package/bin/commands/release.js +34 -1
  10. package/bin/commands/runCloudflare.js +47 -9
  11. package/bin/commands/runServer.js +81 -24
  12. package/bin/commands/toPng.js +346 -0
  13. package/bin/commands/unpack.js +1173 -0
  14. package/bin/ota/loader.js +90 -0
  15. package/bin/utils/appSources.js +101 -0
  16. package/bin/utils/bundleConfig.js +166 -0
  17. package/bin/utils/dtsProps.js +147 -0
  18. package/bin/utils/imageTools.js +271 -0
  19. package/bin/utils/images/bmp.js +99 -0
  20. package/bin/utils/images/gif.js +244 -0
  21. package/bin/utils/images/index.js +163 -0
  22. package/bin/utils/images/jpeg.js +643 -0
  23. package/bin/utils/images/png.js +486 -0
  24. package/bin/utils/images/unsupported.js +29 -0
  25. package/bin/utils/neutralise.js +109 -6
  26. package/bin/utils/templates.js +307 -64
  27. package/docs/cli/README.md +82 -0
  28. package/docs/guides/online-updates.md +57 -9
  29. package/docs/widget/Accordion.md +1 -0
  30. package/docs/widget/AdMob.md +1 -0
  31. package/docs/widget/AdSense.md +1 -0
  32. package/docs/widget/AnimatedBox.md +1 -0
  33. package/docs/widget/AnimatedText.md +1 -0
  34. package/docs/widget/Audio.md +2 -1
  35. package/docs/widget/Avatar.md +12 -3
  36. package/docs/widget/Badge.md +1 -0
  37. package/docs/widget/Button.md +1 -0
  38. package/docs/widget/Card.md +1 -0
  39. package/docs/widget/Carousel.md +1 -0
  40. package/docs/widget/Chart.md +1 -0
  41. package/docs/widget/Checkbox.md +1 -0
  42. package/docs/widget/Chip.md +1 -0
  43. package/docs/widget/CircularBar.md +1 -0
  44. package/docs/widget/CircularChart.md +1 -0
  45. package/docs/widget/CodeViewer.md +1 -0
  46. package/docs/widget/Column.md +1 -0
  47. package/docs/widget/Container.md +1 -0
  48. package/docs/widget/DataTable.md +1 -0
  49. package/docs/widget/Divider.md +1 -0
  50. package/docs/widget/DraggBox.md +3 -2
  51. package/docs/widget/Dropdown.md +3 -0
  52. package/docs/widget/DroppBox.md +2 -1
  53. package/docs/widget/FloatingActionButton.md +1 -0
  54. package/docs/widget/GridView.md +1 -0
  55. package/docs/widget/Icon.md +1 -0
  56. package/docs/widget/Image.md +11 -3
  57. package/docs/widget/Input.md +1 -0
  58. package/docs/widget/InstallButton.md +1 -0
  59. package/docs/widget/ListTile.md +1 -0
  60. package/docs/widget/ListView.md +1 -0
  61. package/docs/widget/Maps.md +188 -0
  62. package/docs/widget/Markdown.md +1 -0
  63. package/docs/widget/MatrixRain.md +1 -0
  64. package/docs/widget/Pagination.md +1 -0
  65. package/docs/widget/ParallaxBox.md +1 -0
  66. package/docs/widget/ProgressBar.md +1 -0
  67. package/docs/widget/QRCode.md +1 -0
  68. package/docs/widget/README.md +12 -10
  69. package/docs/widget/Radio.md +1 -0
  70. package/docs/widget/Rating.md +1 -0
  71. package/docs/widget/Row.md +1 -0
  72. package/docs/widget/Skeleton.md +1 -0
  73. package/docs/widget/Slider.md +1 -0
  74. package/docs/widget/Stack.md +1 -0
  75. package/docs/widget/Stepper.md +1 -0
  76. package/docs/widget/Switch.md +1 -0
  77. package/docs/widget/Text.md +1 -0
  78. package/docs/widget/Tooltip.md +11 -13
  79. package/docs/widget/TreeView.md +1 -0
  80. package/docs/widget/Video.md +3 -2
  81. package/package.json +2 -2
  82. package/src/core/hmr-client.js +129 -34
  83. package/src/index.d.ts +132 -0
  84. package/src/index.js +1 -0
  85. package/src/navigations/AppBar.js +15 -4
  86. package/src/navigations/Drawer.js +9 -1
  87. package/src/widget-factory/translateProps.js +32 -1
  88. package/src/widgets/Avatar.js +228 -131
  89. package/src/widgets/Container.js +13 -0
  90. package/src/widgets/Dropdown.js +33 -1
  91. package/src/widgets/Image.js +102 -3
  92. package/src/widgets/Maps.js +1495 -0
  93. package/src/widgets/Tooltip.js +12 -2
  94. package/src/widgets/index.js +1 -0
@@ -3,16 +3,74 @@
3
3
  * @description A user avatar widget that renders an image, an icon, or
4
4
  * initials derived from a name, depending on what props are provided.
5
5
  * Falls back gracefully: image → icon → initials → generic person icon.
6
- * Supports circular, rounded-square, and square shapes, and exposes a method
7
- * to update its content without rebuilding the DOM.
6
+ * Supports circular, rounded-square, and square shapes, shows an optional
7
+ * presence dot, and applies every one of its own props again on update.
8
8
  */
9
9
 
10
10
  import { WidgetFactory } from "../widget-factory/index.js";
11
11
  import { colors } from "../utils/themes.js";
12
+ import { composeUpdate } from "../utils/composeUpdate.js";
13
+ import {
14
+ isReactive,
15
+ reactiveKeyOf,
16
+ resolveReactive,
17
+ } from "../widget-factory/reactiveValue.js";
18
+ import { registerWidget } from "../tools/useState.js";
12
19
  import { Image } from "./Image.js";
13
20
  import { Icon } from "./Icon.js";
14
21
  import { Text } from "./Text.js";
15
22
 
23
+ /**
24
+ * Props the avatar applies itself: content (image, icon, initials), the box it
25
+ * sits in, and the presence dot. They must never reach the factory — and the
26
+ * factory must not keep them in `_originalProps`, where a later update would
27
+ * re-apply the construction-time value over the widget's own.
28
+ *
29
+ * `bgColor` and `fontSize` are deliberately absent: the engine already routes
30
+ * them (`backgroundColor`, `font-size`), so it can re-apply and re-subscribe
31
+ * them better than this widget can.
32
+ */
33
+ const AVATAR_PROPS = [
34
+ "src",
35
+ "name",
36
+ "icon",
37
+ "size",
38
+ "shape",
39
+ "textColor",
40
+ "online",
41
+ "badgeColor",
42
+ ];
43
+
44
+ /** Diameter of the presence dot, as a share of the avatar. */
45
+ const DOT_SIZE_RATIO = 0.3;
46
+
47
+ /**
48
+ * Gap between the dot and the edge of the avatar. The dot stays inside because
49
+ * the avatar clips its content (`overflow: hidden`) to its own shape: a dot
50
+ * hung on the corner would be cut in half by a circular avatar, and a corner
51
+ * inset of `s·0.12` with a `s·0.3` dot is the largest one that still fits
52
+ * inside a circle of diameter `s`.
53
+ */
54
+ const DOT_INSET_RATIO = 0.12;
55
+
56
+ /** CSS length for a number of pixels; any other value is passed through. */
57
+ const px = (value) => (typeof value === "number" ? `${value}px` : value);
58
+
59
+ /**
60
+ * Extracts up to two initials from a full name.
61
+ * Single-word names return just the first letter; multi-word names use
62
+ * the first letter of the first and last words.
63
+ *
64
+ * @param {string} fullName - The user's full name.
65
+ * @returns {string} 1–2 uppercase initials, or "?" if the name is empty.
66
+ */
67
+ const getInitials = (fullName) => {
68
+ if (!fullName) return "?";
69
+ const parts = fullName.trim().split(/\s+/);
70
+ if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
71
+ return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
72
+ };
73
+
16
74
  /**
17
75
  * Creates an Avatar widget.
18
76
  *
@@ -26,15 +84,32 @@ import { Text } from "./Text.js";
26
84
  * @param {string} [props.src] - URL of a profile image.
27
85
  * @param {string} [props.name] - Full name from which initials are derived (e.g. "Jane Doe" → "JD").
28
86
  * @param {number} [props.size=40] - Diameter of the avatar in pixels.
29
- * @param {number} [props.fontSize=16] - Font size of initials text in pixels (overridden by the auto-computed size when `size` changes).
87
+ * @param {number} [props.fontSize=16] - Font size of the avatar box in pixels; initials are sized from `size`.
30
88
  * @param {'circle'|'rounded'|'square'} [props.shape='circle'] - Shape of the avatar container.
31
89
  * @param {string} [props.bgColor=colors.primary] - Background color shown behind icons or initials.
32
90
  * @param {string} [props.textColor=colors.text] - Color of icon or initials.
33
91
  * @param {string} [props.icon] - Material icon name to display instead of an image or initials.
92
+ * @param {boolean} [props.online=false] - Shows a presence dot in the bottom-right corner.
93
+ * @param {string} [props.badgeColor=colors.success] - Color of the presence dot.
34
94
  * @param {Function} [props.onPress] - Click handler. When provided, a hover scale effect is also applied.
35
- * @returns {HTMLElement} The avatar container element, augmented with an `updateContent` method.
95
+ * @returns {HTMLElement} The avatar element, augmented with an `updateContent` method.
36
96
  */
37
- export const Avatar = (props) => {
97
+ export const Avatar = (inputProps = {}) => {
98
+ // The factory never sees these keys, so it can neither resolve a `useState`
99
+ // value for them (a live reactive is a *function*, and the content branches
100
+ // below only test them for presence) nor subscribe this widget to the key.
101
+ // Both happen here instead: read once, keep the keys, bind after
102
+ // `composeUpdate` has routed a later patch into `applyProps`.
103
+ const boundKeys = new Map();
104
+ const props = { ...inputProps };
105
+ for (const key of AVATAR_PROPS) {
106
+ const value = inputProps[key];
107
+ if (!isReactive(value)) continue;
108
+ props[key] = resolveReactive(value);
109
+ const stateKey = reactiveKeyOf(value);
110
+ if (stateKey) boundKeys.set(key, stateKey);
111
+ }
112
+
38
113
  let {
39
114
  src,
40
115
  name,
@@ -44,165 +119,187 @@ export const Avatar = (props) => {
44
119
  bgColor = colors.primary,
45
120
  textColor = colors.text,
46
121
  icon,
122
+ online = false,
123
+ badgeColor = colors.success,
47
124
  onPress,
48
125
  ...rest
49
126
  } = props;
50
127
 
51
- // Map shape to a CSS border-radius value
52
- let borderRadius = "50%";
53
- if (shape === "rounded") borderRadius = `${size * 0.2}px`;
54
- if (shape === "square") borderRadius = "0";
55
-
56
128
  /**
57
- * Extracts up to two initials from a full name.
58
- * Single-word names return just the first letter; multi-word names use
59
- * the first letter of the first and last words.
60
- *
61
- * @param {string} fullName - The user's full name.
62
- * @returns {string} 1–2 uppercase initials, or "?" if the name is empty.
129
+ * Border radius the current `shape` gives the current `size`. A square has to
130
+ * say so — it is not merely "no radius applied yet", or the radius from the
131
+ * previous shape would stay on the element.
63
132
  */
64
- const getInitials = (fullName) => {
65
- if (!fullName) return "?";
66
- const parts = fullName.trim().split(/\s+/);
67
- if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
68
- return (
69
- parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
70
- ).toUpperCase();
133
+ const radiusFor = () => {
134
+ if (shape === "rounded") return `${size * 0.2}px`;
135
+ if (shape === "square") return "0";
136
+ return "50%";
71
137
  };
72
138
 
73
- // Choose the appropriate child element based on prop priority
74
- let content = null;
75
- if (src) {
76
- content = Image({
77
- src: src,
78
- width: "100%",
79
- height: "100%",
80
- fit: "cover",
81
- });
82
- } else if (icon) {
83
- content = Icon({
84
- name: icon,
85
- size: size * 0.5,
86
- color: textColor,
87
- });
88
- } else if (name) {
89
- const initials = getInitials(name);
90
- content = Text({
91
- text: initials,
92
- color: textColor,
93
- size: size * 0.4,
94
- weight: "bold",
95
- });
96
- } else {
139
+ /** Chooses the child element for the props currently held. */
140
+ const buildContent = () => {
141
+ if (src) {
142
+ return Image({ src, width: "100%", height: "100%", fit: "cover" });
143
+ }
144
+ if (icon) return Icon({ name: icon, size: size * 0.5, color: textColor });
145
+ if (name) {
146
+ return Text({
147
+ text: getInitials(name),
148
+ color: textColor,
149
+ size: size * 0.4,
150
+ weight: "bold",
151
+ });
152
+ }
97
153
  // Generic fallback when no content is supplied
98
- content = Icon({
99
- name: "person",
100
- size: size * 0.5,
101
- color: textColor,
102
- });
103
- }
154
+ return Icon({ name: "person", size: size * 0.5, color: textColor });
155
+ };
104
156
 
105
157
  const avatar = WidgetFactory({
106
158
  widgetName: "Avatar",
159
+ position: "relative",
107
160
  display: "inline-flex",
108
161
  alignItems: "center",
109
162
  justifyContent: "center",
110
- width: size,
111
- height: size,
112
- borderRadius: borderRadius,
113
- backgroundColor: bgColor,
114
- color: textColor,
115
- overflow: "hidden",
163
+ bgColor: bgColor,
164
+ textColor: textColor,
116
165
  fontSize: fontSize,
166
+ overflow: "hidden",
117
167
  fontWeight: "500",
118
168
  userSelect: "none",
119
169
  cursor: onPress ? "pointer" : "default",
120
170
  transition: "transform 0.2s ease",
121
- child: content,
122
171
  onclick: onPress,
123
172
  ...rest,
124
173
  });
125
174
 
126
- // Add a subtle scale-up on hover when the avatar is interactive
127
- if (onPress) {
128
- avatar.addEventListener("mouseenter", () => {
129
- avatar.style.transform = "scale(1.05)";
130
- });
131
- avatar.addEventListener("mouseleave", () => {
132
- avatar.style.transform = "scale(1)";
133
- });
134
- }
175
+ /**
176
+ * Paints the box. Geometry is set here rather than passed to the factory as
177
+ * `width`/`height`/`borderRadius` because the factory keeps those in
178
+ * `_originalProps` and re-applies them on every later update — which would
179
+ * snap the avatar back to its construction-time size. An explicit `width`,
180
+ * `height` or `borderRadius` still wins, as it always has.
181
+ */
182
+ const paintGeometry = () => {
183
+ avatar.style.width = px(rest.width ?? size);
184
+ avatar.style.height = px(rest.height ?? size);
185
+ avatar.style.borderRadius = rest.borderRadius ?? radiusFor();
186
+ };
187
+
188
+ paintGeometry();
189
+
190
+ // Both children are appended, never passed as `child`: the factory re-creates
191
+ // whatever it built for `child` on any update that re-passes it, which would
192
+ // drop the presence dot and revert the content to the construction-time node.
193
+ let content = buildContent();
194
+ const dot = WidgetFactory({
195
+ tag: "div",
196
+ position: "absolute",
197
+ borderRadius: "50%",
198
+ pointerEvents: "none",
199
+ });
200
+ avatar.appendChild(content);
201
+ avatar.appendChild(dot);
202
+
203
+ const paintDot = () => {
204
+ dot.style.width = `${Math.round(size * DOT_SIZE_RATIO)}px`;
205
+ dot.style.height = `${Math.round(size * DOT_SIZE_RATIO)}px`;
206
+ dot.style.right = `${Math.round(size * DOT_INSET_RATIO)}px`;
207
+ dot.style.bottom = `${Math.round(size * DOT_INSET_RATIO)}px`;
208
+ dot.style.backgroundColor = badgeColor;
209
+ dot.style.display = online ? "block" : "none";
210
+ };
135
211
 
136
212
  /**
137
- * Updates the avatar's content and/or dimensions without recreating the element.
138
- * Only the properties present in `newProps` are applied; others are left as-is.
213
+ * Applies the props this widget owns. Only the keys present in `next` are
214
+ * touched; anything absent keeps its current value, so an update that only
215
+ * flips `online` never reloads the image.
139
216
  *
140
- * @param {Object} newProps - Partial props to apply.
141
- * @param {string} [newProps.src] - New image URL.
142
- * @param {string} [newProps.name] - New name (re-derives initials).
143
- * @param {string} [newProps.icon] - New icon name.
144
- * @param {string} [newProps.bgColor] - New background color.
145
- * @param {number|string} [newProps.size] - New avatar diameter.
217
+ * @param {Object} next - Partial props to apply.
146
218
  */
147
- avatar.updateContent = (newProps) => {
148
- // Sync the mutable local variables with any supplied overrides
149
- if (newProps.src !== undefined) src = newProps.src;
150
- if (newProps.name !== undefined) name = newProps.name;
151
- if (newProps.icon !== undefined) icon = newProps.icon;
152
- if (newProps.bgColor !== undefined)
153
- avatar.style.backgroundColor = newProps.bgColor;
154
- if (newProps.size !== undefined) {
155
- const newSize = newProps.size;
156
- avatar.style.width =
157
- typeof newSize === "number" ? `${newSize}px` : newSize;
158
- avatar.style.height =
159
- typeof newSize === "number" ? `${newSize}px` : newSize;
160
- avatar.style.fontSize =
161
- typeof newSize === "number" ? `${newSize * 0.4}px` : "16px";
219
+ const applyProps = (next = {}) => {
220
+ let rebuild = false;
221
+ let resize = false;
222
+
223
+ if ("src" in next) {
224
+ src = next.src;
225
+ rebuild = true;
226
+ }
227
+ if ("icon" in next) {
228
+ icon = next.icon;
229
+ rebuild = true;
230
+ }
231
+ if ("name" in next) {
232
+ name = next.name;
233
+ rebuild = true;
234
+ }
235
+ if ("textColor" in next) {
236
+ textColor = next.textColor;
237
+ avatar.style.color = textColor;
238
+ rebuild = true;
239
+ }
240
+ if ("size" in next) {
241
+ size = next.size;
242
+ rebuild = true;
243
+ resize = true;
162
244
  }
245
+ if ("shape" in next) {
246
+ shape = next.shape;
247
+ resize = true;
248
+ }
249
+ if ("online" in next) online = next.online;
250
+ if ("badgeColor" in next) badgeColor = next.badgeColor;
163
251
 
164
- // Rebuild the inner content using the updated props
165
- let newContent = null;
166
- if (newProps.src !== undefined || src !== undefined) {
167
- const finalSrc = newProps.src !== undefined ? newProps.src : src;
168
- newContent = Image({
169
- src: finalSrc,
170
- width: "100%",
171
- height: "100%",
172
- fit: "cover",
173
- });
174
- } else if (newProps.icon !== undefined || icon !== undefined) {
175
- const finalIcon = newProps.icon !== undefined ? newProps.icon : icon;
176
- const finalSize = newProps.size || size;
177
- newContent = Icon({
178
- name: finalIcon,
179
- size: finalSize * 0.5,
180
- color: textColor,
181
- });
182
- } else if (newProps.name !== undefined || name !== undefined) {
183
- const finalName = newProps.name !== undefined ? newProps.name : name;
184
- const initials = getInitials(finalName);
185
- const finalSize = newProps.size || size;
186
- newContent = Text({
187
- text: initials,
188
- color: textColor,
189
- size: finalSize * 0.4,
190
- weight: "bold",
191
- });
192
- } else {
193
- const finalSize = newProps.size || size;
194
- newContent = Icon({
195
- name: "person",
196
- size: finalSize * 0.5,
197
- color: textColor,
198
- });
252
+ if (resize) paintGeometry();
253
+ if (rebuild) {
254
+ const nextContent = buildContent();
255
+ avatar.replaceChild(nextContent, content);
256
+ content = nextContent;
199
257
  }
258
+ paintDot();
259
+ };
260
+
261
+ paintDot();
262
+
263
+ // Add a subtle scale-up on hover when the avatar is interactive
264
+ const scaleUp = () => {
265
+ avatar.style.transform = "scale(1.05)";
266
+ };
267
+ const scaleDown = () => {
268
+ avatar.style.transform = "scale(1)";
269
+ };
270
+ if (onPress) {
271
+ avatar.addEventListener("mouseenter", scaleUp);
272
+ avatar.addEventListener("mouseleave", scaleDown);
273
+ }
200
274
 
201
- // Replace the existing child with the new content
202
- while (avatar.firstChild) avatar.removeChild(avatar.firstChild);
203
- avatar.appendChild(newContent);
204
- avatar._child = newContent;
275
+ let disposed = false;
276
+ const dispose = () => {
277
+ if (disposed) return;
278
+ disposed = true;
279
+ avatar.removeEventListener("mouseenter", scaleUp);
280
+ avatar.removeEventListener("mouseleave", scaleDown);
205
281
  };
282
+ avatar.onUnmount(dispose);
283
+
284
+ composeUpdate(avatar, AVATAR_PROPS, applyProps);
285
+
286
+ /**
287
+ * Updates the avatar without recreating the element. This is `update()`: the
288
+ * keys the avatar owns are applied here, everything else — `bgColor`,
289
+ * `fontSize`, padding — goes through the factory, so the two public paths can
290
+ * never disagree about what a prop does.
291
+ *
292
+ * @param {Object} newProps - Partial props to apply: `src`, `name`, `icon`,
293
+ * `size`, `shape`, `textColor`, `online`, `badgeColor`, plus any style prop.
294
+ */
295
+ avatar.updateContent = (newProps = {}) => avatar.update(newProps);
296
+
297
+ // Bind after composing: `registerWidget` pushes the current store value
298
+ // through `update`, which must be the composed one that routes owned keys to
299
+ // `applyProps`. It also releases the subscription itself on unmount.
300
+ for (const [propName, stateKey] of boundKeys) {
301
+ registerWidget(stateKey, avatar, propName);
302
+ }
206
303
 
207
304
  return avatar;
208
305
  };
@@ -13,9 +13,21 @@ export const Container = (props = {}) => {
13
13
  overflow = "auto",
14
14
  boxSizing = "border-box",
15
15
  bgColor = colors.surface,
16
+ flex,
17
+ flexGrow,
18
+ flexShrink,
16
19
  ...rest
17
20
  } = props;
18
21
 
22
+ // Container paints `overflow: auto`, and a flex item whose overflow is not
23
+ // `visible` has an automatic minimum size of zero — so by default it shrank
24
+ // to nothing inside a column that overflows. Opting into the flex shorthand
25
+ // still wins, which is how a stretchable body keeps working.
26
+ const flexDefaults =
27
+ flex === undefined && flexGrow === undefined && flexShrink === undefined
28
+ ? { flexShrink: 0 }
29
+ : { flex, flexGrow, flexShrink };
30
+
19
31
  return WidgetFactory({
20
32
  tag: "div",
21
33
  widgetName: "Container",
@@ -24,6 +36,7 @@ export const Container = (props = {}) => {
24
36
  overflow: overflow,
25
37
  boxSizing: boxSizing,
26
38
  backgroundColor: bgColor,
39
+ ...flexDefaults,
27
40
  ...rest,
28
41
  });
29
42
  };
@@ -5,6 +5,8 @@
5
5
  import { WidgetFactory } from "../widget-factory/index.js";
6
6
  import { colors } from "../utils/themes.js";
7
7
  import { composeUpdate } from "../utils/composeUpdate.js";
8
+ import { isReactive, reactiveKeyOf, resolveReactive } from "../widget-factory/reactiveValue.js";
9
+ import { registerWidget } from "../tools/useState.js";
8
10
  import { Container } from "./Container.js";
9
11
  import { Row } from "./Row.js";
10
12
  import { Column } from "./Column.js";
@@ -22,7 +24,32 @@ const DROPDOWN_PROP_KEYS = [
22
24
  "width", "portal",
23
25
  ];
24
26
 
25
- export const Dropdown = (props) => {
27
+ /**
28
+ * The owned keys `update()` can actually apply, so they are also the ones worth
29
+ * subscribing. A callback is never bound: `onChange` has no branch in `update()`.
30
+ */
31
+ const BINDABLE_KEYS = [
32
+ "options", "value", "placeholder", "disabled", "label", "error", "variant",
33
+ "size", "borderRadius", "color", "bgColor", "textColor", "optionHoverColor",
34
+ "clearable",
35
+ ];
36
+
37
+ export const Dropdown = (inputProps = {}) => {
38
+ // The factory never receives these keys, so it can neither resolve a `useState`
39
+ // value for them (a live reactive is a *function*, and `findSelected()` would
40
+ // compare it against the option list and never match) nor subscribe this widget
41
+ // to the key. Both happen here instead: read once, keep the keys, bind after
42
+ // `composeUpdate` has routed a later patch into `update()`.
43
+ const boundKeys = new Map();
44
+ const props = { ...inputProps };
45
+ for (const key of BINDABLE_KEYS) {
46
+ const value = inputProps[key];
47
+ if (!isReactive(value)) continue;
48
+ props[key] = resolveReactive(value);
49
+ const stateKey = reactiveKeyOf(value);
50
+ if (stateKey) boundKeys.set(key, stateKey);
51
+ }
52
+
26
53
  const {
27
54
  options = [],
28
55
  value = null,
@@ -499,6 +526,11 @@ export const Dropdown = (props) => {
499
526
  container.close = closeMenu;
500
527
  composeUpdate(container, DROPDOWN_PROP_KEYS, update);
501
528
 
529
+ // Bind after composing: `registerWidget` pushes the current store value through
530
+ // `update`, which must be the composed one that routes owned keys into this
531
+ // widget's closure. It also releases the subscription on unmount by itself.
532
+ for (const [propName, stateKey] of boundKeys) registerWidget(stateKey, container, propName);
533
+
502
534
  container.onUnmount(() => {
503
535
  closeMenu();
504
536
  });
@@ -1,20 +1,119 @@
1
1
  /**
2
2
  * @file Image.js
3
3
  * @description Thin `<img>` wrapper that forwards all props to the factory.
4
+ * Three props are handled here because they are not CSS the engine can apply:
5
+ * `circular` picks a shape, and `downloadable` decides what a click does.
4
6
  */
5
7
  import { WidgetFactory } from "../widget-factory/index.js";
8
+ import {
9
+ isReactive,
10
+ reactiveKeyOf,
11
+ resolveReactive,
12
+ } from "../widget-factory/reactiveValue.js";
13
+ import { composeUpdate } from "../utils/composeUpdate.js";
14
+ import { registerWidget } from "../tools/useState.js";
15
+
16
+ /**
17
+ * The name the browser offers when saving an image with no `downloadFileName`:
18
+ * the last path segment of the URL, with the query string and fragment removed.
19
+ * A `data:` URL has no path — its base64 tail is not a file name — so it gets a
20
+ * neutral one.
21
+ *
22
+ * @param {string} src - Value of the `src` attribute.
23
+ * @returns {string} A file name.
24
+ */
25
+ const nameFromSrc = (src) => {
26
+ const raw = String(src || "");
27
+ if (!raw || raw.startsWith("data:")) return "image";
28
+ const tail = raw.split("?")[0].split("#")[0].split("/").pop();
29
+ return tail || "image";
30
+ };
31
+
32
+ /**
33
+ * Saves the image under `name`.
34
+ *
35
+ * The `download` attribute is only honoured for a same-origin (or CORS-served)
36
+ * image; for anything else the click would turn into a navigation, which is why
37
+ * the anchor opens in a tab instead of replacing the app.
38
+ *
39
+ * @param {HTMLImageElement} img - The rendered image.
40
+ * @param {string} name - File name to suggest.
41
+ */
42
+ const saveImage = (img, name) => {
43
+ const link = document.createElement("a");
44
+ link.href = img.src;
45
+ link.download = name;
46
+ link.target = "_blank";
47
+ link.rel = "noopener";
48
+ link.click();
49
+ };
6
50
 
7
51
  export const Image = (props) => {
52
+ const inputProps = props || {};
8
53
  // `fit` is now a proper style prop in translateProps (→ object-fit), so it
9
54
  // flows through the factory like any other CSS property. Only `circular`
10
55
  // needs special handling — it maps to borderRadius 50%.
11
- const { circular, ...rest } = props || {};
12
- return WidgetFactory({
56
+ const { circular, downloadable, downloadFileName, ...rest } = inputProps;
57
+
58
+ // Both download props are `special`: nothing routes them, so the factory
59
+ // never sees them and cannot re-apply or re-subscribe them. They are read
60
+ // here instead — resolved once (a live `useState` value is a *function*,
61
+ // which is truthy, so an image bound to `false` would download without this),
62
+ // kept in `boundKeys`, and re-applied through `composeUpdate` when a later
63
+ // patch or store change arrives.
64
+ const boundKeys = new Map();
65
+ let canDownload = Boolean(resolveReactive(downloadable));
66
+ let fileName = resolveReactive(downloadFileName);
67
+ for (const key of ["downloadable", "downloadFileName"]) {
68
+ const value = inputProps[key];
69
+ if (!isReactive(value)) continue;
70
+ const stateKey = reactiveKeyOf(value);
71
+ if (stateKey) boundKeys.set(key, stateKey);
72
+ }
73
+
74
+ const img = WidgetFactory({
13
75
  tag: "img",
14
76
  widgetName: "Image",
15
77
  ...(circular ? { borderRadius: "50%" } : null),
16
78
  ...rest,
17
79
  });
80
+
81
+ // A caller who set `cursor` themselves keeps their own value; this widget
82
+ // only borrows the property while it is offering a save.
83
+ const paintsCursor = rest.cursor == null;
84
+
85
+ const offerSave = () => {
86
+ saveImage(img, fileName ? String(fileName) : nameFromSrc(img.src));
87
+ };
88
+
89
+ let attached = false;
90
+ const applyDownloadProps = () => {
91
+ if (canDownload && !attached) {
92
+ img.addEventListener("click", offerSave);
93
+ attached = true;
94
+ } else if (!canDownload && attached) {
95
+ img.removeEventListener("click", offerSave);
96
+ attached = false;
97
+ }
98
+ if (paintsCursor) img.style.cursor = canDownload ? "pointer" : "";
99
+ };
100
+
101
+ applyDownloadProps();
102
+
103
+ composeUpdate(img, ["downloadable", "downloadFileName"], (next = {}) => {
104
+ if ("downloadable" in next) canDownload = Boolean(resolveReactive(next.downloadable));
105
+ if ("downloadFileName" in next) fileName = resolveReactive(next.downloadFileName);
106
+ applyDownloadProps();
107
+ });
108
+
109
+ // After composing: `registerWidget` pushes the current store value through
110
+ // `update`, which must be the composed one that routes these keys here. It
111
+ // releases the subscription again on unmount.
112
+ for (const [propName, stateKey] of boundKeys) {
113
+ registerWidget(stateKey, img, propName);
114
+ }
115
+
116
+ return img;
18
117
  };
19
118
 
20
- export default Image;
119
+ export default Image;