@xsolla/xui-image-uploader 0.147.1

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.
@@ -0,0 +1,641 @@
1
+ // src/ImageUploader.tsx
2
+ import React, { useRef, useState, useEffect, forwardRef } from "react";
3
+
4
+ // ../../foundation/primitives-native/src/Box.tsx
5
+ import {
6
+ View,
7
+ Pressable,
8
+ Image
9
+ } from "react-native";
10
+ import { jsx } from "react/jsx-runtime";
11
+ var Box = ({
12
+ children,
13
+ onPress,
14
+ onLayout,
15
+ onMoveShouldSetResponder,
16
+ onResponderGrant,
17
+ onResponderMove,
18
+ onResponderRelease,
19
+ onResponderTerminate,
20
+ backgroundColor,
21
+ borderColor,
22
+ borderWidth,
23
+ borderBottomWidth,
24
+ borderBottomColor,
25
+ borderTopWidth,
26
+ borderTopColor,
27
+ borderLeftWidth,
28
+ borderLeftColor,
29
+ borderRightWidth,
30
+ borderRightColor,
31
+ borderRadius,
32
+ borderStyle,
33
+ height,
34
+ padding,
35
+ paddingHorizontal,
36
+ paddingVertical,
37
+ margin,
38
+ marginTop,
39
+ marginBottom,
40
+ marginLeft,
41
+ marginRight,
42
+ flexDirection,
43
+ alignItems,
44
+ justifyContent,
45
+ position,
46
+ top,
47
+ bottom,
48
+ left,
49
+ right,
50
+ width,
51
+ minWidth,
52
+ minHeight,
53
+ maxWidth,
54
+ maxHeight,
55
+ flex,
56
+ overflow,
57
+ zIndex,
58
+ hoverStyle,
59
+ pressStyle,
60
+ style,
61
+ "data-testid": dataTestId,
62
+ testID,
63
+ as,
64
+ src,
65
+ alt,
66
+ ...rest
67
+ }) => {
68
+ const getContainerStyle = (pressed) => ({
69
+ backgroundColor: pressed && pressStyle?.backgroundColor ? pressStyle.backgroundColor : backgroundColor,
70
+ borderColor,
71
+ borderWidth,
72
+ borderBottomWidth,
73
+ borderBottomColor,
74
+ borderTopWidth,
75
+ borderTopColor,
76
+ borderLeftWidth,
77
+ borderLeftColor,
78
+ borderRightWidth,
79
+ borderRightColor,
80
+ borderRadius,
81
+ borderStyle,
82
+ overflow,
83
+ zIndex,
84
+ height,
85
+ width,
86
+ minWidth,
87
+ minHeight,
88
+ maxWidth,
89
+ maxHeight,
90
+ padding,
91
+ paddingHorizontal,
92
+ paddingVertical,
93
+ margin,
94
+ marginTop,
95
+ marginBottom,
96
+ marginLeft,
97
+ marginRight,
98
+ flexDirection,
99
+ alignItems,
100
+ justifyContent,
101
+ position,
102
+ top,
103
+ bottom,
104
+ left,
105
+ right,
106
+ flex,
107
+ ...style
108
+ });
109
+ const finalTestID = dataTestId || testID;
110
+ const {
111
+ role,
112
+ tabIndex,
113
+ onKeyDown,
114
+ onKeyUp,
115
+ "aria-label": _ariaLabel,
116
+ "aria-labelledby": _ariaLabelledBy,
117
+ "aria-current": _ariaCurrent,
118
+ "aria-disabled": _ariaDisabled,
119
+ "aria-live": _ariaLive,
120
+ className,
121
+ "data-testid": _dataTestId,
122
+ ...nativeRest
123
+ } = rest;
124
+ if (as === "img" && src) {
125
+ const imageStyle = {
126
+ width,
127
+ height,
128
+ borderRadius,
129
+ position,
130
+ top,
131
+ bottom,
132
+ left,
133
+ right,
134
+ ...style
135
+ };
136
+ return /* @__PURE__ */ jsx(
137
+ Image,
138
+ {
139
+ source: { uri: src },
140
+ style: imageStyle,
141
+ testID: finalTestID,
142
+ resizeMode: "cover",
143
+ ...nativeRest
144
+ }
145
+ );
146
+ }
147
+ if (onPress) {
148
+ return /* @__PURE__ */ jsx(
149
+ Pressable,
150
+ {
151
+ onPress,
152
+ onLayout,
153
+ onMoveShouldSetResponder,
154
+ onResponderGrant,
155
+ onResponderMove,
156
+ onResponderRelease,
157
+ onResponderTerminate,
158
+ style: ({ pressed }) => getContainerStyle(pressed),
159
+ testID: finalTestID,
160
+ ...nativeRest,
161
+ children
162
+ }
163
+ );
164
+ }
165
+ return /* @__PURE__ */ jsx(
166
+ View,
167
+ {
168
+ style: getContainerStyle(),
169
+ testID: finalTestID,
170
+ onLayout,
171
+ onMoveShouldSetResponder,
172
+ onResponderGrant,
173
+ onResponderMove,
174
+ onResponderRelease,
175
+ onResponderTerminate,
176
+ ...nativeRest,
177
+ children
178
+ }
179
+ );
180
+ };
181
+
182
+ // ../../foundation/primitives-native/src/Text.tsx
183
+ import {
184
+ Text as RNText,
185
+ StyleSheet
186
+ } from "react-native";
187
+ import { jsx as jsx2 } from "react/jsx-runtime";
188
+ var roleMap = {
189
+ alert: "alert",
190
+ heading: "header",
191
+ button: "button",
192
+ link: "link",
193
+ text: "text"
194
+ };
195
+ var parseNumericValue = (value) => {
196
+ if (value === void 0) return void 0;
197
+ if (typeof value === "number") return value;
198
+ const parsed = parseFloat(value);
199
+ return isNaN(parsed) ? void 0 : parsed;
200
+ };
201
+ var Text = ({
202
+ children,
203
+ color,
204
+ fontSize,
205
+ fontWeight,
206
+ fontFamily,
207
+ textAlign,
208
+ lineHeight,
209
+ numberOfLines,
210
+ id,
211
+ role,
212
+ style: styleProp,
213
+ ...props
214
+ }) => {
215
+ let resolvedFontFamily = fontFamily ? fontFamily.split(",")[0].replace(/['"]/g, "").trim() : void 0;
216
+ if (resolvedFontFamily === "Pilat Wide" || resolvedFontFamily === "Pilat Wide Bold" || resolvedFontFamily === "Aktiv Grotesk") {
217
+ resolvedFontFamily = void 0;
218
+ }
219
+ const incomingStyle = StyleSheet.flatten(styleProp);
220
+ const baseStyle = {
221
+ color: color ?? incomingStyle?.color,
222
+ fontSize: typeof fontSize === "number" ? fontSize : void 0,
223
+ fontWeight,
224
+ fontFamily: resolvedFontFamily,
225
+ textDecorationLine: props.textDecoration,
226
+ textAlign: textAlign ?? incomingStyle?.textAlign,
227
+ lineHeight: parseNumericValue(lineHeight ?? incomingStyle?.lineHeight),
228
+ marginTop: parseNumericValue(
229
+ incomingStyle?.marginTop
230
+ ),
231
+ marginBottom: parseNumericValue(
232
+ incomingStyle?.marginBottom
233
+ )
234
+ };
235
+ const accessibilityRole = role ? roleMap[role] : void 0;
236
+ return /* @__PURE__ */ jsx2(
237
+ RNText,
238
+ {
239
+ style: baseStyle,
240
+ numberOfLines,
241
+ testID: id,
242
+ accessibilityRole,
243
+ children
244
+ }
245
+ );
246
+ };
247
+
248
+ // ../../foundation/primitives-native/src/index.tsx
249
+ var isWeb = false;
250
+
251
+ // src/ImageUploader.tsx
252
+ import {
253
+ useId,
254
+ useResolvedTheme
255
+ } from "@xsolla/xui-core";
256
+ import { Image as Image2, TrashCan } from "@xsolla/xui-icons-base";
257
+ import { Spinner } from "@xsolla/xui-spinner";
258
+ import { Fragment, jsx as jsx3, jsxs } from "react/jsx-runtime";
259
+ var ImageUploader = forwardRef(
260
+ ({
261
+ size = "xl",
262
+ placeholder = "Upload",
263
+ uploadingPlaceholder = "Uploading",
264
+ description,
265
+ errorMessage,
266
+ wideView = false,
267
+ accept = "image/*",
268
+ openPicker,
269
+ value,
270
+ onUpload,
271
+ onChange,
272
+ onDelete,
273
+ disabled = false,
274
+ loading = false,
275
+ themeMode,
276
+ themeProductContext
277
+ }, ref) => {
278
+ const { theme } = useResolvedTheme({ themeMode, themeProductContext });
279
+ const fileInputRef = useRef(null);
280
+ const isControlled = value !== void 0;
281
+ const [internalPreview, setInternalPreview] = useState(null);
282
+ const [isHover, setIsHover] = useState(false);
283
+ const [isFocus, setIsFocus] = useState(false);
284
+ const [isPreviewHover, setIsPreviewHover] = useState(false);
285
+ const [isHoverless] = useState(
286
+ () => !isWeb || typeof window !== "undefined" && !!window.matchMedia?.("(hover: none)").matches
287
+ );
288
+ const reactId = useId();
289
+ const baseId = `image-uploader-${reactId.replace(/[^a-zA-Z0-9-]/g, "")}`;
290
+ const descriptionId = `${baseId}-description`;
291
+ const errorId = `${baseId}-error`;
292
+ const preview = isControlled ? value?.url ?? null : internalPreview;
293
+ useEffect(() => {
294
+ if (!isControlled && value === null) setInternalPreview(null);
295
+ }, [isControlled, value]);
296
+ React.useImperativeHandle(
297
+ ref,
298
+ () => fileInputRef.current,
299
+ []
300
+ );
301
+ const sizeStyles = theme.sizing.imageUploader(size);
302
+ const inputColors = theme.colors.control.input;
303
+ const focusColors = theme.colors.control.focus;
304
+ const alertBorder = theme.colors.control.alert.border;
305
+ const alertText = theme.colors.content.alert.primary;
306
+ const scrim = theme.colors.layer.scrim;
307
+ const hasError = !!errorMessage;
308
+ let state;
309
+ if (disabled) state = "disable";
310
+ else if (loading) state = "uploading";
311
+ else if (hasError) state = "error";
312
+ else if (preview)
313
+ state = isPreviewHover || isHoverless ? "uploadedHover" : "uploaded";
314
+ else if (isFocus) state = "focus";
315
+ else if (isHover) state = "hover";
316
+ else state = "default";
317
+ const emitFile = (img) => {
318
+ if (!isControlled) setInternalPreview(img.uri);
319
+ onUpload?.(img);
320
+ onChange?.({
321
+ filename: img.name,
322
+ url: img.uri
323
+ });
324
+ };
325
+ const handleWebFile = (file) => {
326
+ if (!file.type.startsWith("image/")) return;
327
+ const reader = new FileReader();
328
+ reader.onload = (e) => {
329
+ const uri = e.target?.result;
330
+ emitFile({
331
+ name: file.name,
332
+ size: file.size,
333
+ uri,
334
+ mimeType: file.type,
335
+ file
336
+ });
337
+ };
338
+ reader.readAsDataURL(file);
339
+ };
340
+ const handleClick = async () => {
341
+ if (disabled || loading) return;
342
+ if (preview) {
343
+ removeImage();
344
+ return;
345
+ }
346
+ if (openPicker) {
347
+ const picked = await openPicker();
348
+ if (picked) emitFile(picked);
349
+ return;
350
+ }
351
+ if (isWeb) {
352
+ fileInputRef.current?.click();
353
+ }
354
+ };
355
+ const handleFileChange = (e) => {
356
+ const file = e.target.files?.[0];
357
+ e.target.value = "";
358
+ if (file) handleWebFile(file);
359
+ };
360
+ const handleDragOver = (e) => {
361
+ e.preventDefault();
362
+ e.stopPropagation();
363
+ if (!disabled && !loading && !preview) setIsHover(true);
364
+ };
365
+ const handleDragLeave = (e) => {
366
+ e.preventDefault();
367
+ e.stopPropagation();
368
+ setIsHover(false);
369
+ };
370
+ const handleDrop = (e) => {
371
+ e.preventDefault();
372
+ e.stopPropagation();
373
+ setIsHover(false);
374
+ if (disabled || loading || preview) return;
375
+ const file = e.dataTransfer.files?.[0];
376
+ if (file) handleWebFile(file);
377
+ };
378
+ const removeImage = (e) => {
379
+ e?.stopPropagation?.();
380
+ if (!isControlled) setInternalPreview(null);
381
+ setIsPreviewHover(false);
382
+ onDelete?.();
383
+ onChange?.(null);
384
+ if (isWeb && fileInputRef.current) fileInputRef.current.value = "";
385
+ };
386
+ let backgroundColor = inputColors.bg;
387
+ let borderColor = inputColors.border;
388
+ let borderStyle = "dashed";
389
+ let labelColor = inputColors.text;
390
+ let descriptionColor = inputColors.placeholder;
391
+ let iconColor = inputColors.text;
392
+ switch (state) {
393
+ case "hover":
394
+ backgroundColor = inputColors.bgHover;
395
+ borderColor = inputColors.borderHover;
396
+ break;
397
+ case "focus":
398
+ case "uploading":
399
+ backgroundColor = focusColors.bg;
400
+ borderColor = focusColors.border;
401
+ break;
402
+ case "uploaded":
403
+ case "uploadedHover":
404
+ backgroundColor = "transparent";
405
+ borderColor = "transparent";
406
+ break;
407
+ case "error":
408
+ backgroundColor = inputColors.bg;
409
+ borderColor = alertBorder;
410
+ borderStyle = "solid";
411
+ labelColor = alertText;
412
+ iconColor = alertText;
413
+ break;
414
+ case "disable":
415
+ backgroundColor = inputColors.bgDisable;
416
+ borderColor = inputColors.borderDisable;
417
+ labelColor = inputColors.textDisable;
418
+ descriptionColor = inputColors.textDisable;
419
+ iconColor = inputColors.textDisable;
420
+ break;
421
+ }
422
+ const rootGap = state === "error" ? sizeStyles.errorGap : 0;
423
+ const renderInner = () => {
424
+ if (state === "uploaded" || state === "uploadedHover") {
425
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
426
+ /* @__PURE__ */ jsx3(
427
+ Box,
428
+ {
429
+ as: "img",
430
+ src: preview || void 0,
431
+ alt: value?.filename ?? "Uploaded image",
432
+ position: "absolute",
433
+ top: 0,
434
+ left: 0,
435
+ right: 0,
436
+ bottom: 0,
437
+ borderRadius: sizeStyles.radius,
438
+ resizeMode: "cover",
439
+ ...isWeb && { width: "100%", height: "100%" },
440
+ style: isWeb ? { objectFit: "cover" } : void 0
441
+ }
442
+ ),
443
+ state === "uploadedHover" && !disabled && /* @__PURE__ */ jsxs(Fragment, { children: [
444
+ /* @__PURE__ */ jsx3(
445
+ Box,
446
+ {
447
+ position: "absolute",
448
+ top: 0,
449
+ left: 0,
450
+ right: 0,
451
+ bottom: 0,
452
+ backgroundColor: scrim,
453
+ borderRadius: sizeStyles.radius,
454
+ ...isWeb && { "aria-hidden": "true" }
455
+ }
456
+ ),
457
+ /* @__PURE__ */ jsx3(
458
+ Box,
459
+ {
460
+ position: "relative",
461
+ width: sizeStyles.iconSize,
462
+ height: sizeStyles.iconSize,
463
+ alignItems: "center",
464
+ justifyContent: "center",
465
+ zIndex: 1,
466
+ pointerEvents: "none",
467
+ ...isWeb && { "aria-hidden": "true" },
468
+ children: /* @__PURE__ */ jsx3(TrashCan, { size: sizeStyles.iconSize, color: "#ffffff" })
469
+ }
470
+ )
471
+ ] })
472
+ ] });
473
+ }
474
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
475
+ state === "uploading" ? /* @__PURE__ */ jsx3(
476
+ Spinner,
477
+ {
478
+ size,
479
+ color: theme.colors.content.brand.primary,
480
+ ...isWeb && { "aria-hidden": "true" }
481
+ }
482
+ ) : /* @__PURE__ */ jsx3(Box, { ...isWeb && { "aria-hidden": "true" }, children: /* @__PURE__ */ jsx3(Image2, { size: sizeStyles.iconSize, color: iconColor }) }),
483
+ placeholder && /* @__PURE__ */ jsx3(
484
+ Text,
485
+ {
486
+ "data-testid": "image-uploader__placeholder",
487
+ color: labelColor,
488
+ fontSize: sizeStyles.labelFontSize,
489
+ lineHeight: sizeStyles.labelLineHeight,
490
+ fontWeight: "500",
491
+ textAlign: "center",
492
+ numberOfLines: 1,
493
+ style: isWeb ? {
494
+ maxWidth: "100%",
495
+ overflow: "hidden",
496
+ textOverflow: "ellipsis",
497
+ whiteSpace: "nowrap"
498
+ } : void 0,
499
+ children: state === "uploading" ? uploadingPlaceholder : placeholder
500
+ }
501
+ ),
502
+ description && wideView && state !== "uploading" && state !== "error" && (typeof description === "string" ? /* @__PURE__ */ jsx3(
503
+ Text,
504
+ {
505
+ "data-testid": "image-uploader__description",
506
+ color: descriptionColor,
507
+ fontSize: sizeStyles.descriptionFontSize,
508
+ lineHeight: sizeStyles.descriptionLineHeight,
509
+ textAlign: "center",
510
+ numberOfLines: 1,
511
+ style: isWeb ? {
512
+ maxWidth: "100%",
513
+ overflow: "hidden",
514
+ textOverflow: "ellipsis",
515
+ whiteSpace: "nowrap"
516
+ } : void 0,
517
+ ...isWeb && { id: descriptionId },
518
+ children: description
519
+ }
520
+ ) : /* @__PURE__ */ jsx3(
521
+ Box,
522
+ {
523
+ "data-testid": "image-uploader__description",
524
+ ...isWeb && { id: descriptionId },
525
+ children: description
526
+ }
527
+ ))
528
+ ] });
529
+ };
530
+ const webOnlyHandlers = isWeb ? {
531
+ onMouseEnter: () => {
532
+ if (disabled || loading) return;
533
+ if (preview) setIsPreviewHover(true);
534
+ else setIsHover(true);
535
+ },
536
+ onMouseLeave: () => {
537
+ if (preview) setIsPreviewHover(false);
538
+ else setIsHover(false);
539
+ },
540
+ onFocus: (e) => {
541
+ if (disabled || loading || preview) return;
542
+ if (typeof e.target?.matches === "function" && !e.target.matches(":focus-visible")) {
543
+ return;
544
+ }
545
+ setIsFocus(true);
546
+ },
547
+ onBlur: () => setIsFocus(false),
548
+ onDragOver: handleDragOver,
549
+ onDragLeave: handleDragLeave,
550
+ onDrop: handleDrop
551
+ } : {};
552
+ const webOnlyStyle = isWeb ? {
553
+ boxSizing: "border-box",
554
+ display: "flex",
555
+ cursor: disabled || loading ? "not-allowed" : "pointer",
556
+ outline: "none",
557
+ transition: "background-color 0.15s ease, border-color 0.15s ease"
558
+ } : {};
559
+ const buttonAriaLabel = preview ? `Remove image${value?.filename ? `: ${value.filename}` : ""}` : state === "uploading" ? uploadingPlaceholder : placeholder;
560
+ const describedBy = [
561
+ state === "error" && errorMessage ? errorId : null,
562
+ description && wideView && state !== "uploading" && state !== "error" ? descriptionId : null
563
+ ].filter(Boolean).join(" ") || void 0;
564
+ return /* @__PURE__ */ jsxs(
565
+ Box,
566
+ {
567
+ "data-testid": "image-uploader",
568
+ flexDirection: "column",
569
+ gap: rootGap,
570
+ alignItems: "flex-start",
571
+ width: wideView ? "100%" : void 0,
572
+ maxWidth: wideView ? sizeStyles.wideWidth : void 0,
573
+ children: [
574
+ isWeb && /* @__PURE__ */ jsx3(
575
+ "input",
576
+ {
577
+ type: "file",
578
+ ref: fileInputRef,
579
+ accept,
580
+ onChange: handleFileChange,
581
+ style: { display: "none" },
582
+ disabled,
583
+ tabIndex: -1,
584
+ "aria-hidden": "true",
585
+ "data-testid": "image-uploader__input"
586
+ }
587
+ ),
588
+ /* @__PURE__ */ jsx3(
589
+ Box,
590
+ {
591
+ as: isWeb ? "button" : "div",
592
+ disabled: disabled || loading,
593
+ "data-testid": "image-uploader__button",
594
+ onPress: handleClick,
595
+ ...isWeb && {
596
+ "aria-label": buttonAriaLabel,
597
+ "aria-busy": loading || void 0,
598
+ "aria-invalid": state === "error" || void 0,
599
+ "aria-describedby": describedBy
600
+ },
601
+ ...webOnlyHandlers,
602
+ position: "relative",
603
+ width: wideView ? "100%" : sizeStyles.box,
604
+ maxWidth: wideView ? sizeStyles.wideWidth : void 0,
605
+ height: sizeStyles.box,
606
+ flexDirection: "column",
607
+ alignItems: "center",
608
+ justifyContent: "center",
609
+ gap: sizeStyles.gap,
610
+ padding: state === "uploaded" ? 0 : sizeStyles.padding,
611
+ borderWidth: sizeStyles.borderWidth,
612
+ borderStyle,
613
+ borderColor,
614
+ borderRadius: sizeStyles.radius,
615
+ backgroundColor,
616
+ overflow: "hidden",
617
+ style: webOnlyStyle,
618
+ children: renderInner()
619
+ }
620
+ ),
621
+ state === "error" && errorMessage && /* @__PURE__ */ jsx3(
622
+ Text,
623
+ {
624
+ "data-testid": "image-uploader__error",
625
+ color: alertText,
626
+ fontSize: sizeStyles.errorFontSize,
627
+ lineHeight: sizeStyles.errorLineHeight,
628
+ ...isWeb && { id: errorId, role: "alert" },
629
+ children: errorMessage
630
+ }
631
+ )
632
+ ]
633
+ }
634
+ );
635
+ }
636
+ );
637
+ ImageUploader.displayName = "ImageUploader";
638
+ export {
639
+ ImageUploader
640
+ };
641
+ //# sourceMappingURL=index.mjs.map