flet-box 1.0.6 → 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.
- package/README.md +28 -1
- package/bin/cli.js +77 -11
- package/bin/commands/auditFix.js +682 -0
- package/bin/commands/checkApi.js +7 -5
- package/bin/commands/create.js +40 -10
- package/bin/commands/createBundle.js +54 -1
- package/bin/commands/images.js +349 -0
- package/bin/commands/pruneAssets.js +170 -0
- package/bin/commands/release.js +34 -1
- package/bin/commands/runCloudflare.js +47 -9
- package/bin/commands/runServer.js +81 -24
- package/bin/commands/toPng.js +346 -0
- package/bin/commands/unpack.js +1173 -0
- package/bin/ota/loader.js +90 -0
- package/bin/utils/appSources.js +101 -0
- package/bin/utils/bundleConfig.js +166 -0
- package/bin/utils/dtsProps.js +147 -0
- package/bin/utils/imageTools.js +271 -0
- package/bin/utils/images/bmp.js +99 -0
- package/bin/utils/images/gif.js +244 -0
- package/bin/utils/images/index.js +163 -0
- package/bin/utils/images/jpeg.js +643 -0
- package/bin/utils/images/png.js +486 -0
- package/bin/utils/images/unsupported.js +29 -0
- package/bin/utils/neutralise.js +109 -6
- package/bin/utils/templates.js +307 -64
- package/docs/cli/README.md +82 -0
- package/docs/guides/online-updates.md +57 -9
- package/docs/widget/Accordion.md +1 -0
- package/docs/widget/AdMob.md +1 -0
- package/docs/widget/AdSense.md +1 -0
- package/docs/widget/AnimatedBox.md +1 -0
- package/docs/widget/AnimatedText.md +1 -0
- package/docs/widget/Audio.md +2 -1
- package/docs/widget/Avatar.md +12 -3
- package/docs/widget/Badge.md +1 -0
- package/docs/widget/Button.md +1 -0
- package/docs/widget/Card.md +1 -0
- package/docs/widget/Carousel.md +1 -0
- package/docs/widget/Chart.md +1 -0
- package/docs/widget/Checkbox.md +1 -0
- package/docs/widget/Chip.md +1 -0
- package/docs/widget/CircularBar.md +1 -0
- package/docs/widget/CircularChart.md +1 -0
- package/docs/widget/CodeViewer.md +1 -0
- package/docs/widget/Column.md +1 -0
- package/docs/widget/Container.md +1 -0
- package/docs/widget/DataTable.md +1 -0
- package/docs/widget/Divider.md +1 -0
- package/docs/widget/DraggBox.md +3 -2
- package/docs/widget/Dropdown.md +3 -0
- package/docs/widget/DroppBox.md +2 -1
- package/docs/widget/FloatingActionButton.md +1 -0
- package/docs/widget/GridView.md +1 -0
- package/docs/widget/Icon.md +1 -0
- package/docs/widget/Image.md +11 -3
- package/docs/widget/Input.md +1 -0
- package/docs/widget/InstallButton.md +1 -0
- package/docs/widget/ListTile.md +1 -0
- package/docs/widget/ListView.md +1 -0
- package/docs/widget/Maps.md +188 -0
- package/docs/widget/Markdown.md +1 -0
- package/docs/widget/MatrixRain.md +1 -0
- package/docs/widget/Pagination.md +1 -0
- package/docs/widget/ParallaxBox.md +1 -0
- package/docs/widget/ProgressBar.md +1 -0
- package/docs/widget/QRCode.md +1 -0
- package/docs/widget/README.md +12 -10
- package/docs/widget/Radio.md +1 -0
- package/docs/widget/Rating.md +1 -0
- package/docs/widget/Row.md +1 -0
- package/docs/widget/Skeleton.md +1 -0
- package/docs/widget/Slider.md +1 -0
- package/docs/widget/Stack.md +1 -0
- package/docs/widget/Stepper.md +1 -0
- package/docs/widget/Switch.md +1 -0
- package/docs/widget/Text.md +1 -0
- package/docs/widget/Tooltip.md +11 -13
- package/docs/widget/TreeView.md +1 -0
- package/docs/widget/Video.md +3 -2
- package/package.json +2 -2
- package/src/core/hmr-client.js +129 -34
- package/src/index.d.ts +132 -0
- package/src/index.js +1 -0
- package/src/navigations/AppBar.js +15 -4
- package/src/navigations/Drawer.js +9 -1
- package/src/widget-factory/translateProps.js +32 -1
- package/src/widgets/Avatar.js +228 -131
- package/src/widgets/Container.js +13 -0
- package/src/widgets/Dropdown.js +33 -1
- package/src/widgets/Image.js +102 -3
- package/src/widgets/Maps.js +1495 -0
- package/src/widgets/Tooltip.js +12 -2
- package/src/widgets/index.js +1 -0
package/src/widgets/Avatar.js
CHANGED
|
@@ -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,
|
|
7
|
-
*
|
|
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
|
|
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
|
|
95
|
+
* @returns {HTMLElement} The avatar element, augmented with an `updateContent` method.
|
|
36
96
|
*/
|
|
37
|
-
export const Avatar = (
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
*
|
|
138
|
-
*
|
|
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}
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
};
|
package/src/widgets/Container.js
CHANGED
|
@@ -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
|
};
|
package/src/widgets/Dropdown.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/src/widgets/Image.js
CHANGED
|
@@ -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 } =
|
|
12
|
-
|
|
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;
|