@freightos/freightwind 2.1.4 → 2.1.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/dist/cjs/components/multiple-files-upload.js +195 -0
- package/dist/cjs/components/upload.js +108 -26
- package/dist/cjs/index.js +4 -1
- package/dist/esm/components/multiple-files-upload.js +192 -0
- package/dist/esm/components/upload.js +108 -26
- package/dist/esm/index.js +2 -1
- package/dist/types/components/multiple-files-upload.d.ts +33 -0
- package/dist/types/components/upload.d.ts +9 -4
- package/dist/types/index.d.ts +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.MultipleFilesUpload = void 0;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const react_direction_1 = require("@radix-ui/react-direction");
|
|
8
|
+
const utils_1 = require("../lib/utils");
|
|
9
|
+
const icon_utils_1 = require("../lib/icon-utils");
|
|
10
|
+
const use_stable_id_1 = require("../lib/use-stable-id");
|
|
11
|
+
const file_extensions_1 = require("../lib/file-extensions");
|
|
12
|
+
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
|
13
|
+
const DEFAULT_LABELS = {
|
|
14
|
+
idleTitle: 'Upload your document',
|
|
15
|
+
idleSubtitle: 'Click or drag your file here',
|
|
16
|
+
uploading: 'Uploading…',
|
|
17
|
+
forbidden: {
|
|
18
|
+
multiple: { title: 'Oops!', subtitle: 'Too many files' },
|
|
19
|
+
type: { title: 'Oops!', subtitle: 'Unsupported file format' },
|
|
20
|
+
size: { title: 'Oops!', subtitle: 'File is too large' },
|
|
21
|
+
extension: { title: 'Oops!', subtitle: 'Invalid file extension' },
|
|
22
|
+
custom: { title: 'Oops!', subtitle: 'This file is not allowed' },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const identity = (k) => k;
|
|
26
|
+
// ─── Validation helpers ───────────────────────────────────────────────────────
|
|
27
|
+
const EXT_TO_MIME = {
|
|
28
|
+
'.pdf': ['application/pdf'],
|
|
29
|
+
'.png': ['image/png'],
|
|
30
|
+
'.jpg': ['image/jpeg'],
|
|
31
|
+
'.jpeg': ['image/jpeg'],
|
|
32
|
+
'.gif': ['image/gif'],
|
|
33
|
+
'.webp': ['image/webp'],
|
|
34
|
+
'.svg': ['image/svg+xml'],
|
|
35
|
+
'.xlsx': ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
36
|
+
'.xls': ['application/vnd.ms-excel'],
|
|
37
|
+
'.csv': ['text/csv', 'application/csv', 'text/plain'],
|
|
38
|
+
'.doc': ['application/msword'],
|
|
39
|
+
'.docx': ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
40
|
+
'.txt': ['text/plain'],
|
|
41
|
+
'.zip': ['application/zip', 'application/x-zip-compressed'],
|
|
42
|
+
};
|
|
43
|
+
function mimeMatchesAccept(mime, accept) {
|
|
44
|
+
return accept.some((rule) => {
|
|
45
|
+
if (rule.endsWith('/*'))
|
|
46
|
+
return mime.startsWith(rule.slice(0, -1));
|
|
47
|
+
if (rule.startsWith('.'))
|
|
48
|
+
return (EXT_TO_MIME[rule.toLowerCase()] ?? []).includes(mime);
|
|
49
|
+
return mime === rule;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function extMatchesAccept(filename, accept) {
|
|
53
|
+
const dot = filename.lastIndexOf('.');
|
|
54
|
+
if (dot < 0)
|
|
55
|
+
return false;
|
|
56
|
+
const ext = filename.slice(dot).toLowerCase();
|
|
57
|
+
return accept.some((rule) => rule.toLowerCase() === ext);
|
|
58
|
+
}
|
|
59
|
+
function fileMatchesAccept(file, accept) {
|
|
60
|
+
return mimeMatchesAccept(file.type, accept) || extMatchesAccept(file.name, accept);
|
|
61
|
+
}
|
|
62
|
+
function validateDragItems(items, { maxFiles, accept, alreadyHeld = 0 }) {
|
|
63
|
+
if (maxFiles != null && items.length + alreadyHeld > maxFiles)
|
|
64
|
+
return { ok: false, reason: 'multiple' };
|
|
65
|
+
if (accept && accept.length > 0) {
|
|
66
|
+
for (let i = 0; i < items.length; i++) {
|
|
67
|
+
const item = items[i];
|
|
68
|
+
if (!item || item.kind !== 'file')
|
|
69
|
+
continue;
|
|
70
|
+
if (!mimeMatchesAccept(item.type, accept))
|
|
71
|
+
return { ok: false, reason: 'type' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { ok: true };
|
|
75
|
+
}
|
|
76
|
+
function validatePickedFile(file, { accept, maxSize }) {
|
|
77
|
+
if (!(0, file_extensions_1.hasValidExtension)(file.name))
|
|
78
|
+
return { ok: false, reason: 'extension' };
|
|
79
|
+
if (accept && accept.length > 0 && !fileMatchesAccept(file, accept))
|
|
80
|
+
return { ok: false, reason: 'type' };
|
|
81
|
+
if (maxSize != null && file.size > maxSize)
|
|
82
|
+
return { ok: false, reason: 'size' };
|
|
83
|
+
return { ok: true };
|
|
84
|
+
}
|
|
85
|
+
// ─── Component ─────────────────────────────────────────────────────────────────
|
|
86
|
+
exports.MultipleFilesUpload = (0, react_1.forwardRef)(function MultipleFilesUpload({ dataTestId, files = [], accept, maxFiles, maxSize, isDisabled = false, isFillContainer = false, labels, t = identity, onUpload, onRemove, onForbidden, ariaLabel, defaultUploading = false, defaultForbidden, className, ...rest }, ref) {
|
|
87
|
+
const inputId = (0, use_stable_id_1.useStableId)('multi-upload');
|
|
88
|
+
const dir = (0, react_direction_1.useDirection)();
|
|
89
|
+
const [isUploading, setIsUploading] = (0, react_1.useState)(defaultUploading);
|
|
90
|
+
const [forbiddenReason, setForbiddenReason] = (0, react_1.useState)(defaultForbidden ?? null);
|
|
91
|
+
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
|
|
92
|
+
const mergedForbidden = { ...DEFAULT_LABELS.forbidden, ...(labels?.forbidden ?? {}) };
|
|
93
|
+
const fbCopy = mergedForbidden[forbiddenReason ?? 'multiple'];
|
|
94
|
+
const acceptAttr = accept?.join(',');
|
|
95
|
+
const hasFiles = files.length > 0;
|
|
96
|
+
const hasErrors = files.some((e) => e.status === 'error');
|
|
97
|
+
const handleDragEnter = (e) => {
|
|
98
|
+
if (isDisabled)
|
|
99
|
+
return;
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
const result = validateDragItems(e.dataTransfer.items, { maxFiles, accept, alreadyHeld: files.length });
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
setForbiddenReason(result.reason);
|
|
104
|
+
onForbidden?.(result.reason);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const handleDragLeave = (e) => {
|
|
108
|
+
if (e.currentTarget.contains(e.relatedTarget))
|
|
109
|
+
return;
|
|
110
|
+
setForbiddenReason(null);
|
|
111
|
+
};
|
|
112
|
+
const handleDrop = (e) => {
|
|
113
|
+
if (isDisabled)
|
|
114
|
+
return;
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
setForbiddenReason(null);
|
|
117
|
+
const dropped = Array.from(e.dataTransfer.files);
|
|
118
|
+
if (maxFiles != null && dropped.length + files.length > maxFiles) {
|
|
119
|
+
setForbiddenReason('multiple');
|
|
120
|
+
onForbidden?.('multiple');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const f of dropped) {
|
|
124
|
+
const result = validatePickedFile(f, { accept, maxSize });
|
|
125
|
+
if (!result.ok) {
|
|
126
|
+
setForbiddenReason(result.reason);
|
|
127
|
+
onForbidden?.(result.reason);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
triggerUpload(dropped);
|
|
132
|
+
};
|
|
133
|
+
const handleNativeChange = (e) => {
|
|
134
|
+
const selected = Array.from(e.target.files ?? []);
|
|
135
|
+
e.target.value = '';
|
|
136
|
+
if (maxFiles != null && selected.length + files.length > maxFiles) {
|
|
137
|
+
setForbiddenReason('multiple');
|
|
138
|
+
onForbidden?.('multiple');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
for (const f of selected) {
|
|
142
|
+
const result = validatePickedFile(f, { accept, maxSize });
|
|
143
|
+
if (!result.ok) {
|
|
144
|
+
setForbiddenReason(result.reason);
|
|
145
|
+
onForbidden?.(result.reason);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
setForbiddenReason(null);
|
|
150
|
+
triggerUpload(selected);
|
|
151
|
+
};
|
|
152
|
+
const triggerUpload = (selected) => {
|
|
153
|
+
if (selected.length === 0)
|
|
154
|
+
return;
|
|
155
|
+
onUpload?.(selected, (v) => setIsUploading(v));
|
|
156
|
+
};
|
|
157
|
+
const handleRemoveClick = (e, file) => {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
e.stopPropagation();
|
|
160
|
+
onRemove?.(file);
|
|
161
|
+
};
|
|
162
|
+
const DocumentIcon = icon_utils_1.iconMap['document'];
|
|
163
|
+
const SpinnerIcon = icon_utils_1.iconMap['loading'];
|
|
164
|
+
const CheckIcon = icon_utils_1.iconMap['check-circled'];
|
|
165
|
+
const ErrorIcon = icon_utils_1.iconMap['clear-circled'];
|
|
166
|
+
const BlockIcon = icon_utils_1.iconMap['block'];
|
|
167
|
+
const TrashIcon = icon_utils_1.iconMap['trash'];
|
|
168
|
+
const renderDropzoneContent = () => {
|
|
169
|
+
if (isUploading) {
|
|
170
|
+
return ((0, jsx_runtime_1.jsx)(SpinnerIcon, { size: "md", role: "status", "aria-label": t(mergedLabels.uploading), className: "animate-spin text-fds-blue-30 rtl:direction-[reverse]", "data-test-id": `${dataTestId}-uploading-indicator` }));
|
|
171
|
+
}
|
|
172
|
+
if (forbiddenReason) {
|
|
173
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(BlockIcon, { size: "md", className: "shrink-0 text-fds-red-30", "aria-hidden": true, "data-test-id": `${dataTestId}-forbidden-icon` }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-col gap-fds-xs text-start", "aria-live": "polite", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-fds-sm font-fds-regular leading-fds-body text-fds-red-30", "data-test-id": `${dataTestId}-forbidden-title`, children: t(fbCopy?.title ?? '') }), (0, jsx_runtime_1.jsx)("span", { className: "text-fds-xs font-fds-bold leading-fds-body text-fds-red-30", "data-test-id": `${dataTestId}-forbidden-subtitle`, children: t(fbCopy?.subtitle ?? '') })] })] }));
|
|
174
|
+
}
|
|
175
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(DocumentIcon, { size: "md", className: "shrink-0 text-fds-gray-80 rtl:scale-x-[-1] dark:text-fds-gray-20", "aria-hidden": true, "data-test-id": `${dataTestId}-idle-icon` }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-col gap-fds-xs text-start", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-fds-sm font-fds-regular leading-fds-body text-fds-gray-80 dark:text-fds-gray-20", "data-test-id": `${dataTestId}-idle-title`, children: t(mergedLabels.idleTitle) }), (0, jsx_runtime_1.jsx)("span", { className: "text-fds-xs font-fds-bold leading-fds-body text-fds-gray-80 dark:text-fds-gray-20", "data-test-id": `${dataTestId}-idle-subtitle`, children: t(mergedLabels.idleSubtitle) })] })] }));
|
|
176
|
+
};
|
|
177
|
+
const renderFileRow = (entry, index) => {
|
|
178
|
+
const isSuccess = entry.status === 'success';
|
|
179
|
+
const RowIcon = isSuccess ? CheckIcon : ErrorIcon;
|
|
180
|
+
const iconColor = isSuccess ? 'text-fds-green-30' : 'text-fds-red-30';
|
|
181
|
+
const textColor = isSuccess ? 'text-fds-blue-30' : 'text-fds-red-30';
|
|
182
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex h-[40px] w-full items-center gap-fds-sm", "data-test-id": `${dataTestId}-file-row-${index}`, children: [(0, jsx_runtime_1.jsx)(RowIcon, { size: "sm", className: (0, utils_1.cn)('shrink-0', iconColor), "aria-hidden": true, "data-test-id": `${dataTestId}-file-status-${index}` }), (0, jsx_runtime_1.jsx)("span", { className: (0, utils_1.cn)('min-w-0 flex-1 truncate text-fds-base font-fds-regular leading-fds-body', textColor), title: entry.file.name, "data-test-id": `${dataTestId}-filename-${index}`, children: entry.file.name }), !isDisabled && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: (e) => handleRemoveClick(e, entry.file), "aria-label": t(`Remove ${entry.file.name}`), "data-test-id": `${dataTestId}-trash-${index}`, className: "inline-flex h-[40px] w-[63px] shrink-0 items-center justify-center cursor-pointer text-fds-red-30 hover:bg-fds-red-05 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fds-red-30 dark:hover:bg-fds-red-30/10", children: (0, jsx_runtime_1.jsx)(TrashIcon, { size: "sm", "aria-hidden": true }) }))] }, `${entry.file.name}-${index}`));
|
|
183
|
+
};
|
|
184
|
+
const dropzoneBorderClass = forbiddenReason
|
|
185
|
+
? 'upload-dash-fluid-red'
|
|
186
|
+
: isUploading
|
|
187
|
+
? 'border border-solid border-fds-gray-30 dark:border-fds-gray-70'
|
|
188
|
+
: hasErrors && hasFiles
|
|
189
|
+
? 'border border-solid border-fds-red-30 dark:border-fds-red-30'
|
|
190
|
+
: hasFiles
|
|
191
|
+
? 'border border-solid border-fds-blue-30 dark:border-fds-blue-30'
|
|
192
|
+
: 'upload-dash-fluid';
|
|
193
|
+
const dropzoneHeight = isFillContainer ? 'h-[147px]' : 'h-[69px]';
|
|
194
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: ref, dir: dir, "data-test-id": dataTestId, "data-state": isUploading ? 'uploading' : hasErrors ? 'errors' : hasFiles ? 'success' : forbiddenReason ? 'forbidden' : 'default', className: (0, utils_1.cn)('relative flex flex-col rounded-fds-md bg-white p-[24px] gap-[10px] dark:bg-fds-gray-95', isFillContainer ? 'w-full' : 'w-[433px]', isDisabled && 'pointer-events-none opacity-60', className), ...rest, children: [(0, jsx_runtime_1.jsxs)("label", { htmlFor: inputId, "aria-label": ariaLabel ?? t(mergedLabels.idleTitle), "aria-disabled": isDisabled || undefined, "aria-busy": isUploading || undefined, className: (0, utils_1.cn)('fw-base relative flex w-full cursor-pointer select-none items-center gap-fds-sm rounded-fds-md p-fds-lg transition-colors', 'bg-fds-gray-10 dark:bg-fds-gray-90', dropzoneHeight, dropzoneBorderClass, isDisabled && 'cursor-not-allowed', isUploading && 'cursor-uploading justify-center', forbiddenReason && 'cursor-not-allowed justify-center'), onDragEnter: handleDragEnter, onDragOver: (e) => e.preventDefault(), onDragLeave: handleDragLeave, onDrop: handleDrop, children: [(0, jsx_runtime_1.jsx)("input", { id: inputId, type: "file", accept: acceptAttr, multiple: maxFiles == null || maxFiles > 1, disabled: isDisabled, className: "sr-only", onChange: handleNativeChange, "data-test-id": `${dataTestId}-input` }), renderDropzoneContent()] }), hasFiles && !isUploading && ((0, jsx_runtime_1.jsx)("div", { className: "flex w-full flex-col", "data-test-id": `${dataTestId}-file-list`, children: files.map((entry, i) => renderFileRow(entry, i)) }))] }));
|
|
195
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
'use client';
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.uploadVariants = exports.Upload = void 0;
|
|
4
|
+
exports.SingleFileUpload = exports.uploadVariants = exports.Upload = void 0;
|
|
5
5
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
6
|
const react_1 = require("react");
|
|
7
7
|
const class_variance_authority_1 = require("class-variance-authority");
|
|
@@ -10,11 +10,14 @@ const utils_1 = require("../lib/utils");
|
|
|
10
10
|
const icon_utils_1 = require("../lib/icon-utils");
|
|
11
11
|
const use_stable_id_1 = require("../lib/use-stable-id");
|
|
12
12
|
const file_extensions_1 = require("../lib/file-extensions");
|
|
13
|
+
const SQUARE_SIZE = 'h-[100px] w-[100px]';
|
|
14
|
+
const SQUARE_LG_SIZE = 'h-[238px] w-[285px]';
|
|
13
15
|
const uploadVariants = (0, class_variance_authority_1.cva)('fw-base group relative flex select-none rounded-fds-md outline-none transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-fds-blue-30 has-[:focus-visible]:ring-offset-2', {
|
|
14
16
|
variants: {
|
|
15
17
|
variant: {
|
|
16
|
-
rectangle: 'items-center gap-fds-
|
|
17
|
-
square:
|
|
18
|
+
rectangle: 'items-center gap-fds-md p-fds-lg',
|
|
19
|
+
square: `${SQUARE_SIZE} flex-col items-center justify-center gap-[10px] p-fds-xl`,
|
|
20
|
+
'square-lg': `${SQUARE_LG_SIZE} flex-col items-center justify-center gap-fds-lg p-fds-xl`,
|
|
18
21
|
},
|
|
19
22
|
state: {
|
|
20
23
|
default: 'cursor-pointer justify-center bg-fds-gray-10 dark:bg-fds-gray-90',
|
|
@@ -33,7 +36,10 @@ const DEFAULT_LABELS = {
|
|
|
33
36
|
idleTitle: 'Upload your document',
|
|
34
37
|
idleSubtitle: 'Click or drag your file here',
|
|
35
38
|
idleCompact: 'Upload',
|
|
39
|
+
idleDragDrop: 'Drag and drop your file',
|
|
36
40
|
uploading: 'Uploading…',
|
|
41
|
+
uploadingTitle: 'Hang on...',
|
|
42
|
+
uploadingSubtitle: 'We’re uploading all your details',
|
|
37
43
|
forbidden: {
|
|
38
44
|
multiple: { title: 'Oops!', subtitle: 'You can only drag one file', compact: 'Oops!' },
|
|
39
45
|
type: { title: 'Oops!', subtitle: 'Unsupported file format', compact: 'Oops!' },
|
|
@@ -121,6 +127,14 @@ function validateDragItems(items, { maxFiles, accept, alreadyHeld = 0 }) {
|
|
|
121
127
|
}
|
|
122
128
|
return { ok: true };
|
|
123
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* Run every per-file gate in the order the UI cares about:
|
|
132
|
+
* 1. global extension whitelist (catches `document.mohtadi`),
|
|
133
|
+
* 2. instance-specific `accept` list,
|
|
134
|
+
* 3. `maxSize`.
|
|
135
|
+
*
|
|
136
|
+
* Returned reason maps directly onto a {@link ForbiddenReason} branch.
|
|
137
|
+
*/
|
|
124
138
|
function validatePickedFile(file, { accept, maxSize }) {
|
|
125
139
|
if (!(0, file_extensions_1.hasValidExtension)(file.name))
|
|
126
140
|
return { ok: false, reason: 'extension' };
|
|
@@ -145,7 +159,7 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
|
|
|
145
159
|
const fbCopy = mergedForbidden[forbiddenReason ?? 'multiple'];
|
|
146
160
|
const allFiles = filesProp ?? (value ? [value] : []);
|
|
147
161
|
const hasFiles = allFiles.length > 0;
|
|
148
|
-
const
|
|
162
|
+
const baseEffectiveState = isDisabled
|
|
149
163
|
? 'disabled'
|
|
150
164
|
: forbiddenReason
|
|
151
165
|
? 'forbidden'
|
|
@@ -157,8 +171,23 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
|
|
|
157
171
|
? 'success'
|
|
158
172
|
: 'default';
|
|
159
173
|
const isSquare = variant === 'square';
|
|
174
|
+
const isSquareLg = variant === 'square-lg';
|
|
175
|
+
const isRectangle = !isSquare && !isSquareLg;
|
|
160
176
|
const isMultiple = maxFiles == null || maxFiles > 1;
|
|
161
177
|
const acceptAttr = accept?.join(',');
|
|
178
|
+
// Multi-file list mode: rectangle with 2+ files and explicit maxFiles>1 renders the drop zone
|
|
179
|
+
// in its idle state on top, and a per-file list of rows below (each row = check + filename + trash).
|
|
180
|
+
const isMultiFileList = isRectangle &&
|
|
181
|
+
hasFiles &&
|
|
182
|
+
maxFiles != null &&
|
|
183
|
+
maxFiles > 1 &&
|
|
184
|
+
allFiles.length >= 2 &&
|
|
185
|
+
!isError &&
|
|
186
|
+
!isDisabled &&
|
|
187
|
+
!isUploading &&
|
|
188
|
+
!forbiddenReason;
|
|
189
|
+
// In list mode, the drop zone renders idle so users can drop more files; file status lives in rows below.
|
|
190
|
+
const effectiveState = isMultiFileList ? 'default' : baseEffectiveState;
|
|
162
191
|
const triggerUpload = (selected) => {
|
|
163
192
|
if (selected.length === 0)
|
|
164
193
|
return;
|
|
@@ -230,11 +259,14 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
|
|
|
230
259
|
onRemove?.();
|
|
231
260
|
};
|
|
232
261
|
const renderIdle = () => {
|
|
233
|
-
const idleIconName = isSquare ? DEFAULT_ICONS.idleSquare : DEFAULT_ICONS.idleRectangle;
|
|
262
|
+
const idleIconName = (isSquare || isSquareLg) ? DEFAULT_ICONS.idleSquare : DEFAULT_ICONS.idleRectangle;
|
|
234
263
|
const IconCmp = icon_utils_1.iconMap[idleIconName];
|
|
235
264
|
const tone = effectiveState === 'disabled'
|
|
236
265
|
? 'text-fds-gray-50 dark:text-fds-gray-50'
|
|
237
266
|
: 'text-fds-gray-80 dark:text-fds-gray-20';
|
|
267
|
+
if (isSquareLg) {
|
|
268
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(IconCmp, { className: (0, utils_1.cn)('!w-7 !h-7', tone), "aria-hidden": true, "data-test-id": `${dataTestId}-idle-icon` }), (0, jsx_runtime_1.jsx)("span", { className: (0, utils_1.cn)('text-fds-base font-fds-regular leading-fds-body text-center', tone), "data-test-id": `${dataTestId}-idle-compact`, children: t(mergedLabels.idleDragDrop) })] }));
|
|
269
|
+
}
|
|
238
270
|
if (!isSquare) {
|
|
239
271
|
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(IconCmp, { size: "md", className: (0, utils_1.cn)('shrink-0 rtl:scale-x-[-1]', tone), "aria-hidden": true, "data-test-id": `${dataTestId}-idle-icon` }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-col gap-fds-xs text-start", children: [(0, jsx_runtime_1.jsx)("span", { className: (0, utils_1.cn)('w-[130px] text-fds-sm font-fds-regular leading-fds-body', tone), "data-test-id": `${dataTestId}-idle-title`, children: t(mergedLabels.idleTitle) }), (0, jsx_runtime_1.jsx)("span", { className: (0, utils_1.cn)('w-[132px] text-fds-xs font-fds-bold leading-fds-body', tone), "data-test-id": `${dataTestId}-idle-subtitle`, children: t(mergedLabels.idleSubtitle) })] })] }));
|
|
240
272
|
}
|
|
@@ -242,17 +274,27 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
|
|
|
242
274
|
};
|
|
243
275
|
const renderForbidden = () => {
|
|
244
276
|
const IconCmp = icon_utils_1.iconMap[DEFAULT_ICONS.forbidden];
|
|
245
|
-
if (!isSquare) {
|
|
277
|
+
if (!isSquare && !isSquareLg) {
|
|
246
278
|
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(IconCmp, { size: "md", className: "shrink-0 text-fds-red-30", "aria-hidden": true, "data-test-id": `${dataTestId}-forbidden-icon` }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-col gap-fds-xs text-start", "aria-live": "polite", children: [(0, jsx_runtime_1.jsx)("span", { className: "w-[130px] text-fds-sm font-fds-regular leading-fds-body text-fds-red-30", "data-test-id": `${dataTestId}-forbidden-title`, children: t(fbCopy?.title ?? '') }), (0, jsx_runtime_1.jsx)("span", { className: "w-[132px] text-fds-xs font-fds-bold leading-fds-body text-fds-red-30", "data-test-id": `${dataTestId}-forbidden-subtitle`, children: t(fbCopy?.subtitle ?? '') })] })] }));
|
|
247
279
|
}
|
|
248
280
|
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(IconCmp, { size: "lg", className: "text-fds-red-30", "aria-hidden": true, "data-test-id": `${dataTestId}-forbidden-icon` }), (0, jsx_runtime_1.jsx)("span", { className: "text-fds-base font-fds-regular leading-fds-body text-fds-red-30", "data-test-id": `${dataTestId}-forbidden-compact`, children: t(mergedLabels.idleCompact) })] }));
|
|
249
281
|
};
|
|
250
282
|
const renderUploading = () => {
|
|
251
283
|
const SpinnerIcon = icon_utils_1.iconMap[DEFAULT_ICONS.uploading];
|
|
284
|
+
// Square-lg: 40px spinner + title + subtitle, centered vertically with 16px gap (Figma spec).
|
|
285
|
+
if (isSquareLg) {
|
|
286
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col items-center justify-center gap-fds-lg text-center", "data-test-id": `${dataTestId}-uploading-indicator`, children: [(0, jsx_runtime_1.jsx)(SpinnerIcon, { className: "w-10 h-10 animate-spin text-fds-blue-30 rtl:direction-[reverse]", role: "status", "aria-label": t(mergedLabels.uploading) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex w-[190px] flex-col items-center", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-fds-h6 font-fds-bold leading-fds-title text-fds-gray-80 dark:text-fds-gray-20", children: t(mergedLabels.uploadingTitle) }), (0, jsx_runtime_1.jsx)("span", { className: "text-fds-xs font-fds-regular leading-fds-body text-fds-gray-80 dark:text-fds-gray-20", children: t(mergedLabels.uploadingSubtitle) })] })] }));
|
|
287
|
+
}
|
|
288
|
+
// Parts F + H: spinner-only — text is NEVER rendered here (Rule 1 / Part H square rule).
|
|
252
289
|
if (uploadingStyle === 'spinner') {
|
|
253
290
|
return ((0, jsx_runtime_1.jsx)(SpinnerIcon, { size: "lg", role: "status", "aria-label": t(mergedLabels.uploading), className: "animate-spin text-fds-blue-30 rtl:direction-[reverse]", "data-test-id": `${dataTestId}-uploading-indicator` }));
|
|
254
291
|
}
|
|
255
|
-
|
|
292
|
+
// Parts E + G: progress bar.
|
|
293
|
+
// Fill uses absolute+start-0 so it anchors to the logical-start edge — left in LTR,
|
|
294
|
+
// right in RTL — giving correct fill direction in both reading modes.
|
|
295
|
+
// The existing [dir="rtl"] .upload-progress-bar keyframe swap in globals.css covers
|
|
296
|
+
// the indeterminate animation direction automatically.
|
|
297
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)('flex flex-col', isSquare ? 'w-[78px] gap-[13px]' : 'w-full gap-fds-sm'), "data-test-id": `${dataTestId}-uploading-indicator`, children: [(0, jsx_runtime_1.jsx)("span", { className: (0, utils_1.cn)('font-fds-regular leading-fds-body text-fds-gray-80 dark:text-fds-gray-20', isSquare ? 'w-[78px] text-fds-base' : 'w-[67px] text-fds-sm'), children: t(mergedLabels.uploading) }), (0, jsx_runtime_1.jsx)("div", { className: (0, utils_1.cn)('relative h-0.5 overflow-hidden rounded-[6px] bg-fds-gray-20 dark:bg-fds-gray-70', isSquare ? 'w-[78px]' : 'w-[165px]'), role: "progressbar", "aria-valuenow": uploadProgress !== null ? Math.round(uploadProgress * 100) : undefined, "aria-valuemin": uploadProgress !== null ? 0 : undefined, "aria-valuemax": uploadProgress !== null ? 100 : undefined, "aria-label": t(mergedLabels.uploading), children: (0, jsx_runtime_1.jsx)("div", { className: (0, utils_1.cn)('absolute inset-y-0 start-0 rounded-[6px] bg-fds-green-30 upload-progress-bar', uploadProgress === null
|
|
256
298
|
? 'w-1/2 animate-[uploading-progress_1.4s_ease-in-out_infinite]'
|
|
257
299
|
: 'transition-[width] duration-200 ease-in-out'), style: uploadProgress !== null ? { width: `${uploadProgress * 100}%` } : undefined }) })] }));
|
|
258
300
|
};
|
|
@@ -261,7 +303,7 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
|
|
|
261
303
|
? 'text-fds-blue-30'
|
|
262
304
|
: tone === 'error'
|
|
263
305
|
? 'text-fds-red-30'
|
|
264
|
-
: 'text-fds-gray-
|
|
306
|
+
: 'text-fds-gray-50 dark:text-fds-gray-50';
|
|
265
307
|
const accent = tone === 'success'
|
|
266
308
|
? 'text-fds-green-30'
|
|
267
309
|
: tone === 'error'
|
|
@@ -269,15 +311,35 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
|
|
|
269
311
|
: 'text-fds-gray-30 dark:text-fds-gray-50';
|
|
270
312
|
const StatusIcon = icon_utils_1.iconMap[tone === 'error' ? DEFAULT_ICONS.error : DEFAULT_ICONS.success];
|
|
271
313
|
const RemoveIcon = icon_utils_1.iconMap[DEFAULT_ICONS.remove];
|
|
272
|
-
const { display, tooltip } = buildFileLabel(allFiles, isSquare);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
314
|
+
const { display, tooltip } = buildFileLabel(allFiles, isSquare || isSquareLg);
|
|
315
|
+
// square-lg success (Figma Frame 6): 237×123 column, 8px gap between four children —
|
|
316
|
+
// 40px green check-circled icon (Semantic Green 30 #47A96E)
|
|
317
|
+
// file names: 184×18, 12px/18px Regular, Primary Blue 30 (#2075BD), centered
|
|
318
|
+
// "Hang on..." title: H6 16px/24px Bold (Gray 80)
|
|
319
|
+
// "We're uploading all your details" subtitle: 12px/18px Regular (Gray 80)
|
|
320
|
+
if (isSquareLg && tone === 'success') {
|
|
321
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex w-[237px] flex-col items-center gap-fds-sm text-center", children: [(0, jsx_runtime_1.jsx)(StatusIcon, { className: "w-10 h-10 shrink-0 text-fds-green-30", "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` }), (0, jsx_runtime_1.jsx)("span", { className: "w-[184px] truncate text-fds-xs font-fds-regular leading-fds-body text-fds-blue-30 cursor-default", title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), (0, jsx_runtime_1.jsx)("span", { className: "w-[190px] text-fds-h6 font-fds-bold leading-fds-title text-fds-gray-80 dark:text-fds-gray-20", children: t(mergedLabels.uploadingTitle) }), (0, jsx_runtime_1.jsx)("span", { className: "w-[190px] text-fds-xs font-fds-regular leading-fds-body text-fds-gray-80 dark:text-fds-gray-20", children: t(mergedLabels.uploadingSubtitle) })] }));
|
|
322
|
+
}
|
|
323
|
+
// square-lg disabled (filled): mirrors success layout but muted —
|
|
324
|
+
// 40px gray check-circled icon, gray filenames, gray "Hang on..." title + subtitle.
|
|
325
|
+
// Solid gray border + flat gray background come from the cva 'disabled' state.
|
|
326
|
+
if (isSquareLg && tone === 'disabled') {
|
|
327
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex w-[237px] flex-col items-center gap-fds-sm text-center", children: [(0, jsx_runtime_1.jsx)(StatusIcon, { className: "w-10 h-10 shrink-0 text-fds-gray-50 dark:text-fds-gray-50", "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` }), (0, jsx_runtime_1.jsx)("span", { className: "w-[184px] truncate text-fds-xs font-fds-regular leading-fds-body text-fds-gray-50 cursor-default dark:text-fds-gray-50", title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), (0, jsx_runtime_1.jsx)("span", { className: "w-[190px] text-fds-h6 font-fds-bold leading-fds-title text-fds-gray-50 dark:text-fds-gray-50", children: t(mergedLabels.uploadingTitle) }), (0, jsx_runtime_1.jsx)("span", { className: "w-[190px] text-fds-xs font-fds-regular leading-fds-body text-fds-gray-50 dark:text-fds-gray-50", children: t(mergedLabels.uploadingSubtitle) })] }));
|
|
328
|
+
}
|
|
329
|
+
// square-lg error: large icon, truncated filenames with native tooltip, error text block
|
|
330
|
+
if (isSquareLg && tone === 'error') {
|
|
331
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(StatusIcon, { size: "lg", className: "shrink-0 text-fds-red-30", "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` }), (0, jsx_runtime_1.jsx)("span", { className: "w-full truncate text-center text-fds-xs font-fds-regular leading-fds-body text-fds-blue-30 cursor-default", title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), (0, jsx_runtime_1.jsxs)("div", { className: "flex w-full flex-col items-center gap-fds-xs text-center", "aria-live": "polite", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-fds-h6 font-fds-bold leading-fds-title text-fds-gray-80 dark:text-fds-gray-20", children: t('Error') }), (0, jsx_runtime_1.jsx)("span", { className: "text-fds-xs font-fds-regular leading-fds-body text-fds-gray-80 dark:text-fds-gray-20", children: t('We were unable to read your information') }), (0, jsx_runtime_1.jsx)("span", { className: "pointer-events-auto cursor-pointer! text-fds-xs font-fds-regular leading-fds-body text-fds-blue-30 underline", children: t('Try again by selecting another file') })] })] }));
|
|
332
|
+
}
|
|
333
|
+
const isSquareAny = isSquare || isSquareLg;
|
|
334
|
+
const statusIconNode = isSquareAny
|
|
278
335
|
? (0, jsx_runtime_1.jsx)(StatusIcon, { size: "lg", className: (0, utils_1.cn)('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })
|
|
279
|
-
: (0, jsx_runtime_1.jsx)(StatusIcon, { size: "
|
|
280
|
-
|
|
336
|
+
: (0, jsx_runtime_1.jsx)(StatusIcon, { size: "xs", className: (0, utils_1.cn)('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` });
|
|
337
|
+
if (isSquareAny) {
|
|
338
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [statusIconNode, (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-fds-xs", children: [(0, jsx_runtime_1.jsx)("span", { className: (0, utils_1.cn)('min-w-0 truncate font-fds-regular leading-fds-body text-fds-sm', tokenColor), title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), tone !== 'disabled' && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: handleRemoveClick, "aria-label": t('Remove all files'), "data-test-id": `${dataTestId}-remove`, className: "inline-flex size-4 shrink-0 items-center justify-center rounded-fds-sm focus-visible:outline-none focus-visible:ring-2 cursor-pointer text-fds-red-30 hover:bg-fds-red-05 focus-visible:ring-fds-red-30 dark:hover:bg-fds-red-30/10", children: (0, jsx_runtime_1.jsx)(RemoveIcon, { size: "sm", "aria-hidden": true }) }))] })] }));
|
|
339
|
+
}
|
|
340
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [statusIconNode, (0, jsx_runtime_1.jsx)("span", { className: (0, utils_1.cn)('truncate font-fds-regular leading-fds-body',
|
|
341
|
+
// Square error/disabled: 14px/21px LH per Parts L/N. Square success/others: 12px/18px (text-fds-sm).
|
|
342
|
+
isSquareAny && (tone === 'error' || tone === 'disabled') ? 'text-fds-base' : 'text-fds-sm', !isSquareAny && !isFillContainer && 'min-w-0 flex-1', !isSquareAny && isFillContainer && 'min-w-0', tokenColor, isSquareAny && 'w-full text-center'), title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), !isSquareAny && hasFiles && tone === 'disabled' && ((0, jsx_runtime_1.jsx)("span", { "aria-hidden": true, "data-test-id": `${dataTestId}-remove-disabled`, className: (0, utils_1.cn)('inline-flex size-4 shrink-0 items-center justify-center text-fds-gray-50 dark:text-fds-gray-50', !isFillContainer && 'ms-auto'), children: (0, jsx_runtime_1.jsx)(RemoveIcon, { size: "sm", "aria-hidden": true }) })), !isSquareAny && hasFiles && tone !== 'disabled' && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: handleRemoveClick, "aria-label": t('Remove all files'), "data-test-id": `${dataTestId}-remove`, className: (0, utils_1.cn)('inline-flex size-4 shrink-0 items-center justify-center rounded-fds-sm focus-visible:outline-none focus-visible:ring-2 cursor-pointer text-fds-red-30 hover:bg-fds-red-05 focus-visible:ring-fds-red-30 dark:hover:bg-fds-red-30/10', !isFillContainer && 'ms-auto'), children: (0, jsx_runtime_1.jsx)(RemoveIcon, { size: "sm", "aria-hidden": true }) }))] }));
|
|
281
343
|
};
|
|
282
344
|
let body = null;
|
|
283
345
|
switch (effectiveState) {
|
|
@@ -306,16 +368,36 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
|
|
|
306
368
|
effectiveState === 'success' ||
|
|
307
369
|
effectiveState === 'error' ||
|
|
308
370
|
(effectiveState === 'disabled' && hasFiles);
|
|
309
|
-
|
|
371
|
+
// All rectangle states share the same unified 198×69px frame (Figma spec, 2026-05-06).
|
|
372
|
+
const frameSize = (isSquare || isSquareLg)
|
|
310
373
|
? ''
|
|
311
374
|
: isFillContainer
|
|
312
|
-
? isFilled ? 'h-full w-full justify-center' : 'h-full w-full'
|
|
313
|
-
:
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
375
|
+
? (isFilled ? 'h-full w-full justify-center' : 'h-full w-full')
|
|
376
|
+
: 'h-[69px] w-[198px]';
|
|
377
|
+
const labelNode = ((0, jsx_runtime_1.jsxs)("label", { ref: ref, htmlFor: inputId, "data-test-id": dataTestId, "data-state": effectiveState, "data-variant": variant, "data-filled": hasFiles ? 'true' : 'false', dir: dir, "aria-label": ariaLabel ?? t(mergedLabels.idleTitle), "aria-disabled": isDisabled || undefined, "aria-busy": (effectiveState === 'uploading' || effectiveState === 'uploading-spinner') || undefined, className: (0, utils_1.cn)(uploadVariants({ variant, state: effectiveState }),
|
|
378
|
+
// Default state: fluid gradient border for fill-container (scales with element),
|
|
379
|
+
// fixed SVG border otherwise (pixel-exact per Figma spec).
|
|
380
|
+
// Square: pixel-perfect 100×99 SVG — identical stroke on all four sides, no corner overlap.
|
|
381
|
+
// Rectangle: fixed SVG at spec dimensions, fluid gradient when stretching to fill container.
|
|
382
|
+
effectiveState === 'default' && (isSquareLg ? 'upload-dash-4-2-square-lg' :
|
|
383
|
+
isSquare ? 'upload-dash-4-2-square' :
|
|
384
|
+
isFillContainer ? 'upload-dash-fluid' : 'upload-dash-4-2'), effectiveState === 'forbidden' && (isSquareLg ? 'upload-dash-fluid-red' :
|
|
385
|
+
isSquare ? 'upload-dash-4-4' :
|
|
386
|
+
isFillContainer ? 'upload-dash-fluid-red' : 'upload-dash-4-2-red'), (effectiveState === 'uploading' || effectiveState === 'uploading-spinner') && isSquareLg && 'border-0 upload-dash-4-2-square-lg',
|
|
387
|
+
// square-lg success: dashed gray border per Figma (overrides solid blue from cva)
|
|
388
|
+
effectiveState === 'success' && isSquareLg && 'border-0 upload-dash-4-2-square-lg',
|
|
389
|
+
// square-lg error: gray border (Figma spec) + allow retry clicks
|
|
390
|
+
effectiveState === 'error' && isSquareLg && 'border-0 upload-dash-4-2-square-lg cursor-pointer!', effectiveState === 'uploading-spinner' && !isSquare && 'py-3.5!', frameSize, className), onDragEnter: handleDragEnter, onDragOver: (e) => e.preventDefault(), onDragLeave: handleDragLeave, onDrop: handleDrop, ...rest, children: [(0, jsx_runtime_1.jsx)("input", { id: inputId, type: "file", accept: acceptAttr, multiple: isMultiple, disabled: isDisabled, className: "sr-only", onChange: handleNativeChange, "data-test-id": `${dataTestId}-input` }), body] }));
|
|
391
|
+
if (!isMultiFileList) {
|
|
392
|
+
return labelNode;
|
|
393
|
+
}
|
|
394
|
+
const RowCheckIcon = icon_utils_1.iconMap[DEFAULT_ICONS.success];
|
|
395
|
+
const RowTrashIcon = icon_utils_1.iconMap[DEFAULT_ICONS.remove];
|
|
396
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)('flex flex-col gap-fds-sm', isFillContainer ? 'w-full' : 'w-[198px]'), "data-test-id": `${dataTestId}-list`, dir: dir, children: [labelNode, (0, jsx_runtime_1.jsx)("ul", { className: "flex flex-col", "data-test-id": `${dataTestId}-files`, children: allFiles.map((f, idx) => ((0, jsx_runtime_1.jsxs)("li", { className: "flex items-center gap-fds-sm border-b border-fds-gray-20 py-fds-sm last:border-b-0 dark:border-fds-gray-70", "data-test-id": `${dataTestId}-file-${idx}`, children: [(0, jsx_runtime_1.jsx)(RowCheckIcon, { size: "sm", className: "shrink-0 text-fds-green-30", "aria-hidden": true }), (0, jsx_runtime_1.jsx)("span", { className: "min-w-0 flex-1 truncate text-fds-sm font-fds-regular leading-fds-body text-fds-blue-30", title: f.name, "data-test-id": `${dataTestId}-file-${idx}-name`, children: f.name }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: (e) => {
|
|
397
|
+
e.preventDefault();
|
|
398
|
+
e.stopPropagation();
|
|
399
|
+
onRemove?.(idx);
|
|
400
|
+
}, "aria-label": t('Remove file'), "data-test-id": `${dataTestId}-file-${idx}-remove`, className: "inline-flex size-4 shrink-0 items-center justify-center rounded-fds-sm focus-visible:outline-none focus-visible:ring-2 cursor-pointer text-fds-red-30 hover:bg-fds-red-05 focus-visible:ring-fds-red-30 dark:hover:bg-fds-red-30/10", children: (0, jsx_runtime_1.jsx)(RowTrashIcon, { size: "sm", "aria-hidden": true }) })] }, `${f.name}-${idx}`))) })] }));
|
|
321
401
|
});
|
|
402
|
+
/** Alias for `Upload` enforcing single-file semantics. Use instead of `Upload` for all new code. */
|
|
403
|
+
exports.SingleFileUpload = exports.Upload;
|
package/dist/cjs/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.uploadVariants = exports.Upload = exports.renderInputIcon = exports.iconMap = exports.useStableId = exports.cn = void 0;
|
|
3
|
+
exports.MultipleFilesUpload = exports.SingleFileUpload = exports.uploadVariants = exports.Upload = exports.renderInputIcon = exports.iconMap = exports.useStableId = exports.cn = void 0;
|
|
4
4
|
// Utilities
|
|
5
5
|
var utils_1 = require("./lib/utils");
|
|
6
6
|
Object.defineProperty(exports, "cn", { enumerable: true, get: function () { return utils_1.cn; } });
|
|
@@ -13,3 +13,6 @@ Object.defineProperty(exports, "renderInputIcon", { enumerable: true, get: funct
|
|
|
13
13
|
var upload_1 = require("./components/upload");
|
|
14
14
|
Object.defineProperty(exports, "Upload", { enumerable: true, get: function () { return upload_1.Upload; } });
|
|
15
15
|
Object.defineProperty(exports, "uploadVariants", { enumerable: true, get: function () { return upload_1.uploadVariants; } });
|
|
16
|
+
Object.defineProperty(exports, "SingleFileUpload", { enumerable: true, get: function () { return upload_1.SingleFileUpload; } });
|
|
17
|
+
var multiple_files_upload_1 = require("./components/multiple-files-upload");
|
|
18
|
+
Object.defineProperty(exports, "MultipleFilesUpload", { enumerable: true, get: function () { return multiple_files_upload_1.MultipleFilesUpload; } });
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, useState, } from 'react';
|
|
4
|
+
import { useDirection } from '@radix-ui/react-direction';
|
|
5
|
+
import { cn } from '../lib/utils';
|
|
6
|
+
import { iconMap } from '../lib/icon-utils';
|
|
7
|
+
import { useStableId } from '../lib/use-stable-id';
|
|
8
|
+
import { hasValidExtension } from '../lib/file-extensions';
|
|
9
|
+
// ─── Defaults ─────────────────────────────────────────────────────────────────
|
|
10
|
+
const DEFAULT_LABELS = {
|
|
11
|
+
idleTitle: 'Upload your document',
|
|
12
|
+
idleSubtitle: 'Click or drag your file here',
|
|
13
|
+
uploading: 'Uploading…',
|
|
14
|
+
forbidden: {
|
|
15
|
+
multiple: { title: 'Oops!', subtitle: 'Too many files' },
|
|
16
|
+
type: { title: 'Oops!', subtitle: 'Unsupported file format' },
|
|
17
|
+
size: { title: 'Oops!', subtitle: 'File is too large' },
|
|
18
|
+
extension: { title: 'Oops!', subtitle: 'Invalid file extension' },
|
|
19
|
+
custom: { title: 'Oops!', subtitle: 'This file is not allowed' },
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const identity = (k) => k;
|
|
23
|
+
// ─── Validation helpers ───────────────────────────────────────────────────────
|
|
24
|
+
const EXT_TO_MIME = {
|
|
25
|
+
'.pdf': ['application/pdf'],
|
|
26
|
+
'.png': ['image/png'],
|
|
27
|
+
'.jpg': ['image/jpeg'],
|
|
28
|
+
'.jpeg': ['image/jpeg'],
|
|
29
|
+
'.gif': ['image/gif'],
|
|
30
|
+
'.webp': ['image/webp'],
|
|
31
|
+
'.svg': ['image/svg+xml'],
|
|
32
|
+
'.xlsx': ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
33
|
+
'.xls': ['application/vnd.ms-excel'],
|
|
34
|
+
'.csv': ['text/csv', 'application/csv', 'text/plain'],
|
|
35
|
+
'.doc': ['application/msword'],
|
|
36
|
+
'.docx': ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
37
|
+
'.txt': ['text/plain'],
|
|
38
|
+
'.zip': ['application/zip', 'application/x-zip-compressed'],
|
|
39
|
+
};
|
|
40
|
+
function mimeMatchesAccept(mime, accept) {
|
|
41
|
+
return accept.some((rule) => {
|
|
42
|
+
if (rule.endsWith('/*'))
|
|
43
|
+
return mime.startsWith(rule.slice(0, -1));
|
|
44
|
+
if (rule.startsWith('.'))
|
|
45
|
+
return (EXT_TO_MIME[rule.toLowerCase()] ?? []).includes(mime);
|
|
46
|
+
return mime === rule;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function extMatchesAccept(filename, accept) {
|
|
50
|
+
const dot = filename.lastIndexOf('.');
|
|
51
|
+
if (dot < 0)
|
|
52
|
+
return false;
|
|
53
|
+
const ext = filename.slice(dot).toLowerCase();
|
|
54
|
+
return accept.some((rule) => rule.toLowerCase() === ext);
|
|
55
|
+
}
|
|
56
|
+
function fileMatchesAccept(file, accept) {
|
|
57
|
+
return mimeMatchesAccept(file.type, accept) || extMatchesAccept(file.name, accept);
|
|
58
|
+
}
|
|
59
|
+
function validateDragItems(items, { maxFiles, accept, alreadyHeld = 0 }) {
|
|
60
|
+
if (maxFiles != null && items.length + alreadyHeld > maxFiles)
|
|
61
|
+
return { ok: false, reason: 'multiple' };
|
|
62
|
+
if (accept && accept.length > 0) {
|
|
63
|
+
for (let i = 0; i < items.length; i++) {
|
|
64
|
+
const item = items[i];
|
|
65
|
+
if (!item || item.kind !== 'file')
|
|
66
|
+
continue;
|
|
67
|
+
if (!mimeMatchesAccept(item.type, accept))
|
|
68
|
+
return { ok: false, reason: 'type' };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { ok: true };
|
|
72
|
+
}
|
|
73
|
+
function validatePickedFile(file, { accept, maxSize }) {
|
|
74
|
+
if (!hasValidExtension(file.name))
|
|
75
|
+
return { ok: false, reason: 'extension' };
|
|
76
|
+
if (accept && accept.length > 0 && !fileMatchesAccept(file, accept))
|
|
77
|
+
return { ok: false, reason: 'type' };
|
|
78
|
+
if (maxSize != null && file.size > maxSize)
|
|
79
|
+
return { ok: false, reason: 'size' };
|
|
80
|
+
return { ok: true };
|
|
81
|
+
}
|
|
82
|
+
// ─── Component ─────────────────────────────────────────────────────────────────
|
|
83
|
+
export const MultipleFilesUpload = forwardRef(function MultipleFilesUpload({ dataTestId, files = [], accept, maxFiles, maxSize, isDisabled = false, isFillContainer = false, labels, t = identity, onUpload, onRemove, onForbidden, ariaLabel, defaultUploading = false, defaultForbidden, className, ...rest }, ref) {
|
|
84
|
+
const inputId = useStableId('multi-upload');
|
|
85
|
+
const dir = useDirection();
|
|
86
|
+
const [isUploading, setIsUploading] = useState(defaultUploading);
|
|
87
|
+
const [forbiddenReason, setForbiddenReason] = useState(defaultForbidden ?? null);
|
|
88
|
+
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
|
|
89
|
+
const mergedForbidden = { ...DEFAULT_LABELS.forbidden, ...(labels?.forbidden ?? {}) };
|
|
90
|
+
const fbCopy = mergedForbidden[forbiddenReason ?? 'multiple'];
|
|
91
|
+
const acceptAttr = accept?.join(',');
|
|
92
|
+
const hasFiles = files.length > 0;
|
|
93
|
+
const hasErrors = files.some((e) => e.status === 'error');
|
|
94
|
+
const handleDragEnter = (e) => {
|
|
95
|
+
if (isDisabled)
|
|
96
|
+
return;
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
const result = validateDragItems(e.dataTransfer.items, { maxFiles, accept, alreadyHeld: files.length });
|
|
99
|
+
if (!result.ok) {
|
|
100
|
+
setForbiddenReason(result.reason);
|
|
101
|
+
onForbidden?.(result.reason);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const handleDragLeave = (e) => {
|
|
105
|
+
if (e.currentTarget.contains(e.relatedTarget))
|
|
106
|
+
return;
|
|
107
|
+
setForbiddenReason(null);
|
|
108
|
+
};
|
|
109
|
+
const handleDrop = (e) => {
|
|
110
|
+
if (isDisabled)
|
|
111
|
+
return;
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
setForbiddenReason(null);
|
|
114
|
+
const dropped = Array.from(e.dataTransfer.files);
|
|
115
|
+
if (maxFiles != null && dropped.length + files.length > maxFiles) {
|
|
116
|
+
setForbiddenReason('multiple');
|
|
117
|
+
onForbidden?.('multiple');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const f of dropped) {
|
|
121
|
+
const result = validatePickedFile(f, { accept, maxSize });
|
|
122
|
+
if (!result.ok) {
|
|
123
|
+
setForbiddenReason(result.reason);
|
|
124
|
+
onForbidden?.(result.reason);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
triggerUpload(dropped);
|
|
129
|
+
};
|
|
130
|
+
const handleNativeChange = (e) => {
|
|
131
|
+
const selected = Array.from(e.target.files ?? []);
|
|
132
|
+
e.target.value = '';
|
|
133
|
+
if (maxFiles != null && selected.length + files.length > maxFiles) {
|
|
134
|
+
setForbiddenReason('multiple');
|
|
135
|
+
onForbidden?.('multiple');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const f of selected) {
|
|
139
|
+
const result = validatePickedFile(f, { accept, maxSize });
|
|
140
|
+
if (!result.ok) {
|
|
141
|
+
setForbiddenReason(result.reason);
|
|
142
|
+
onForbidden?.(result.reason);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
setForbiddenReason(null);
|
|
147
|
+
triggerUpload(selected);
|
|
148
|
+
};
|
|
149
|
+
const triggerUpload = (selected) => {
|
|
150
|
+
if (selected.length === 0)
|
|
151
|
+
return;
|
|
152
|
+
onUpload?.(selected, (v) => setIsUploading(v));
|
|
153
|
+
};
|
|
154
|
+
const handleRemoveClick = (e, file) => {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
e.stopPropagation();
|
|
157
|
+
onRemove?.(file);
|
|
158
|
+
};
|
|
159
|
+
const DocumentIcon = iconMap['document'];
|
|
160
|
+
const SpinnerIcon = iconMap['loading'];
|
|
161
|
+
const CheckIcon = iconMap['check-circled'];
|
|
162
|
+
const ErrorIcon = iconMap['clear-circled'];
|
|
163
|
+
const BlockIcon = iconMap['block'];
|
|
164
|
+
const TrashIcon = iconMap['trash'];
|
|
165
|
+
const renderDropzoneContent = () => {
|
|
166
|
+
if (isUploading) {
|
|
167
|
+
return (_jsx(SpinnerIcon, { size: "md", role: "status", "aria-label": t(mergedLabels.uploading), className: "animate-spin text-fds-blue-30 rtl:direction-[reverse]", "data-test-id": `${dataTestId}-uploading-indicator` }));
|
|
168
|
+
}
|
|
169
|
+
if (forbiddenReason) {
|
|
170
|
+
return (_jsxs(_Fragment, { children: [_jsx(BlockIcon, { size: "md", className: "shrink-0 text-fds-red-30", "aria-hidden": true, "data-test-id": `${dataTestId}-forbidden-icon` }), _jsxs("div", { className: "flex min-w-0 flex-col gap-fds-xs text-start", "aria-live": "polite", children: [_jsx("span", { className: "text-fds-sm font-fds-regular leading-fds-body text-fds-red-30", "data-test-id": `${dataTestId}-forbidden-title`, children: t(fbCopy?.title ?? '') }), _jsx("span", { className: "text-fds-xs font-fds-bold leading-fds-body text-fds-red-30", "data-test-id": `${dataTestId}-forbidden-subtitle`, children: t(fbCopy?.subtitle ?? '') })] })] }));
|
|
171
|
+
}
|
|
172
|
+
return (_jsxs(_Fragment, { children: [_jsx(DocumentIcon, { size: "md", className: "shrink-0 text-fds-gray-80 rtl:scale-x-[-1] dark:text-fds-gray-20", "aria-hidden": true, "data-test-id": `${dataTestId}-idle-icon` }), _jsxs("div", { className: "flex min-w-0 flex-col gap-fds-xs text-start", children: [_jsx("span", { className: "text-fds-sm font-fds-regular leading-fds-body text-fds-gray-80 dark:text-fds-gray-20", "data-test-id": `${dataTestId}-idle-title`, children: t(mergedLabels.idleTitle) }), _jsx("span", { className: "text-fds-xs font-fds-bold leading-fds-body text-fds-gray-80 dark:text-fds-gray-20", "data-test-id": `${dataTestId}-idle-subtitle`, children: t(mergedLabels.idleSubtitle) })] })] }));
|
|
173
|
+
};
|
|
174
|
+
const renderFileRow = (entry, index) => {
|
|
175
|
+
const isSuccess = entry.status === 'success';
|
|
176
|
+
const RowIcon = isSuccess ? CheckIcon : ErrorIcon;
|
|
177
|
+
const iconColor = isSuccess ? 'text-fds-green-30' : 'text-fds-red-30';
|
|
178
|
+
const textColor = isSuccess ? 'text-fds-blue-30' : 'text-fds-red-30';
|
|
179
|
+
return (_jsxs("div", { className: "flex h-[40px] w-full items-center gap-fds-sm", "data-test-id": `${dataTestId}-file-row-${index}`, children: [_jsx(RowIcon, { size: "sm", className: cn('shrink-0', iconColor), "aria-hidden": true, "data-test-id": `${dataTestId}-file-status-${index}` }), _jsx("span", { className: cn('min-w-0 flex-1 truncate text-fds-base font-fds-regular leading-fds-body', textColor), title: entry.file.name, "data-test-id": `${dataTestId}-filename-${index}`, children: entry.file.name }), !isDisabled && (_jsx("button", { type: "button", onClick: (e) => handleRemoveClick(e, entry.file), "aria-label": t(`Remove ${entry.file.name}`), "data-test-id": `${dataTestId}-trash-${index}`, className: "inline-flex h-[40px] w-[63px] shrink-0 items-center justify-center cursor-pointer text-fds-red-30 hover:bg-fds-red-05 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fds-red-30 dark:hover:bg-fds-red-30/10", children: _jsx(TrashIcon, { size: "sm", "aria-hidden": true }) }))] }, `${entry.file.name}-${index}`));
|
|
180
|
+
};
|
|
181
|
+
const dropzoneBorderClass = forbiddenReason
|
|
182
|
+
? 'upload-dash-fluid-red'
|
|
183
|
+
: isUploading
|
|
184
|
+
? 'border border-solid border-fds-gray-30 dark:border-fds-gray-70'
|
|
185
|
+
: hasErrors && hasFiles
|
|
186
|
+
? 'border border-solid border-fds-red-30 dark:border-fds-red-30'
|
|
187
|
+
: hasFiles
|
|
188
|
+
? 'border border-solid border-fds-blue-30 dark:border-fds-blue-30'
|
|
189
|
+
: 'upload-dash-fluid';
|
|
190
|
+
const dropzoneHeight = isFillContainer ? 'h-[147px]' : 'h-[69px]';
|
|
191
|
+
return (_jsxs("div", { ref: ref, dir: dir, "data-test-id": dataTestId, "data-state": isUploading ? 'uploading' : hasErrors ? 'errors' : hasFiles ? 'success' : forbiddenReason ? 'forbidden' : 'default', className: cn('relative flex flex-col rounded-fds-md bg-white p-[24px] gap-[10px] dark:bg-fds-gray-95', isFillContainer ? 'w-full' : 'w-[433px]', isDisabled && 'pointer-events-none opacity-60', className), ...rest, children: [_jsxs("label", { htmlFor: inputId, "aria-label": ariaLabel ?? t(mergedLabels.idleTitle), "aria-disabled": isDisabled || undefined, "aria-busy": isUploading || undefined, className: cn('fw-base relative flex w-full cursor-pointer select-none items-center gap-fds-sm rounded-fds-md p-fds-lg transition-colors', 'bg-fds-gray-10 dark:bg-fds-gray-90', dropzoneHeight, dropzoneBorderClass, isDisabled && 'cursor-not-allowed', isUploading && 'cursor-uploading justify-center', forbiddenReason && 'cursor-not-allowed justify-center'), onDragEnter: handleDragEnter, onDragOver: (e) => e.preventDefault(), onDragLeave: handleDragLeave, onDrop: handleDrop, children: [_jsx("input", { id: inputId, type: "file", accept: acceptAttr, multiple: maxFiles == null || maxFiles > 1, disabled: isDisabled, className: "sr-only", onChange: handleNativeChange, "data-test-id": `${dataTestId}-input` }), renderDropzoneContent()] }), hasFiles && !isUploading && (_jsx("div", { className: "flex w-full flex-col", "data-test-id": `${dataTestId}-file-list`, children: files.map((entry, i) => renderFileRow(entry, i)) }))] }));
|
|
192
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { forwardRef, useState, } from 'react';
|
|
4
4
|
import { cva } from 'class-variance-authority';
|
|
5
5
|
import { useDirection } from '@radix-ui/react-direction';
|
|
@@ -7,11 +7,14 @@ import { cn } from '../lib/utils';
|
|
|
7
7
|
import { iconMap } from '../lib/icon-utils';
|
|
8
8
|
import { useStableId } from '../lib/use-stable-id';
|
|
9
9
|
import { hasValidExtension } from '../lib/file-extensions';
|
|
10
|
+
const SQUARE_SIZE = 'h-[100px] w-[100px]';
|
|
11
|
+
const SQUARE_LG_SIZE = 'h-[238px] w-[285px]';
|
|
10
12
|
const uploadVariants = cva('fw-base group relative flex select-none rounded-fds-md outline-none transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-fds-blue-30 has-[:focus-visible]:ring-offset-2', {
|
|
11
13
|
variants: {
|
|
12
14
|
variant: {
|
|
13
|
-
rectangle: 'items-center gap-fds-
|
|
14
|
-
square:
|
|
15
|
+
rectangle: 'items-center gap-fds-md p-fds-lg',
|
|
16
|
+
square: `${SQUARE_SIZE} flex-col items-center justify-center gap-[10px] p-fds-xl`,
|
|
17
|
+
'square-lg': `${SQUARE_LG_SIZE} flex-col items-center justify-center gap-fds-lg p-fds-xl`,
|
|
15
18
|
},
|
|
16
19
|
state: {
|
|
17
20
|
default: 'cursor-pointer justify-center bg-fds-gray-10 dark:bg-fds-gray-90',
|
|
@@ -29,7 +32,10 @@ const DEFAULT_LABELS = {
|
|
|
29
32
|
idleTitle: 'Upload your document',
|
|
30
33
|
idleSubtitle: 'Click or drag your file here',
|
|
31
34
|
idleCompact: 'Upload',
|
|
35
|
+
idleDragDrop: 'Drag and drop your file',
|
|
32
36
|
uploading: 'Uploading…',
|
|
37
|
+
uploadingTitle: 'Hang on...',
|
|
38
|
+
uploadingSubtitle: 'We’re uploading all your details',
|
|
33
39
|
forbidden: {
|
|
34
40
|
multiple: { title: 'Oops!', subtitle: 'You can only drag one file', compact: 'Oops!' },
|
|
35
41
|
type: { title: 'Oops!', subtitle: 'Unsupported file format', compact: 'Oops!' },
|
|
@@ -117,6 +123,14 @@ function validateDragItems(items, { maxFiles, accept, alreadyHeld = 0 }) {
|
|
|
117
123
|
}
|
|
118
124
|
return { ok: true };
|
|
119
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Run every per-file gate in the order the UI cares about:
|
|
128
|
+
* 1. global extension whitelist (catches `document.mohtadi`),
|
|
129
|
+
* 2. instance-specific `accept` list,
|
|
130
|
+
* 3. `maxSize`.
|
|
131
|
+
*
|
|
132
|
+
* Returned reason maps directly onto a {@link ForbiddenReason} branch.
|
|
133
|
+
*/
|
|
120
134
|
function validatePickedFile(file, { accept, maxSize }) {
|
|
121
135
|
if (!hasValidExtension(file.name))
|
|
122
136
|
return { ok: false, reason: 'extension' };
|
|
@@ -141,7 +155,7 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
|
|
|
141
155
|
const fbCopy = mergedForbidden[forbiddenReason ?? 'multiple'];
|
|
142
156
|
const allFiles = filesProp ?? (value ? [value] : []);
|
|
143
157
|
const hasFiles = allFiles.length > 0;
|
|
144
|
-
const
|
|
158
|
+
const baseEffectiveState = isDisabled
|
|
145
159
|
? 'disabled'
|
|
146
160
|
: forbiddenReason
|
|
147
161
|
? 'forbidden'
|
|
@@ -153,8 +167,23 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
|
|
|
153
167
|
? 'success'
|
|
154
168
|
: 'default';
|
|
155
169
|
const isSquare = variant === 'square';
|
|
170
|
+
const isSquareLg = variant === 'square-lg';
|
|
171
|
+
const isRectangle = !isSquare && !isSquareLg;
|
|
156
172
|
const isMultiple = maxFiles == null || maxFiles > 1;
|
|
157
173
|
const acceptAttr = accept?.join(',');
|
|
174
|
+
// Multi-file list mode: rectangle with 2+ files and explicit maxFiles>1 renders the drop zone
|
|
175
|
+
// in its idle state on top, and a per-file list of rows below (each row = check + filename + trash).
|
|
176
|
+
const isMultiFileList = isRectangle &&
|
|
177
|
+
hasFiles &&
|
|
178
|
+
maxFiles != null &&
|
|
179
|
+
maxFiles > 1 &&
|
|
180
|
+
allFiles.length >= 2 &&
|
|
181
|
+
!isError &&
|
|
182
|
+
!isDisabled &&
|
|
183
|
+
!isUploading &&
|
|
184
|
+
!forbiddenReason;
|
|
185
|
+
// In list mode, the drop zone renders idle so users can drop more files; file status lives in rows below.
|
|
186
|
+
const effectiveState = isMultiFileList ? 'default' : baseEffectiveState;
|
|
158
187
|
const triggerUpload = (selected) => {
|
|
159
188
|
if (selected.length === 0)
|
|
160
189
|
return;
|
|
@@ -226,11 +255,14 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
|
|
|
226
255
|
onRemove?.();
|
|
227
256
|
};
|
|
228
257
|
const renderIdle = () => {
|
|
229
|
-
const idleIconName = isSquare ? DEFAULT_ICONS.idleSquare : DEFAULT_ICONS.idleRectangle;
|
|
258
|
+
const idleIconName = (isSquare || isSquareLg) ? DEFAULT_ICONS.idleSquare : DEFAULT_ICONS.idleRectangle;
|
|
230
259
|
const IconCmp = iconMap[idleIconName];
|
|
231
260
|
const tone = effectiveState === 'disabled'
|
|
232
261
|
? 'text-fds-gray-50 dark:text-fds-gray-50'
|
|
233
262
|
: 'text-fds-gray-80 dark:text-fds-gray-20';
|
|
263
|
+
if (isSquareLg) {
|
|
264
|
+
return (_jsxs(_Fragment, { children: [_jsx(IconCmp, { className: cn('!w-7 !h-7', tone), "aria-hidden": true, "data-test-id": `${dataTestId}-idle-icon` }), _jsx("span", { className: cn('text-fds-base font-fds-regular leading-fds-body text-center', tone), "data-test-id": `${dataTestId}-idle-compact`, children: t(mergedLabels.idleDragDrop) })] }));
|
|
265
|
+
}
|
|
234
266
|
if (!isSquare) {
|
|
235
267
|
return (_jsxs(_Fragment, { children: [_jsx(IconCmp, { size: "md", className: cn('shrink-0 rtl:scale-x-[-1]', tone), "aria-hidden": true, "data-test-id": `${dataTestId}-idle-icon` }), _jsxs("div", { className: "flex min-w-0 flex-col gap-fds-xs text-start", children: [_jsx("span", { className: cn('w-[130px] text-fds-sm font-fds-regular leading-fds-body', tone), "data-test-id": `${dataTestId}-idle-title`, children: t(mergedLabels.idleTitle) }), _jsx("span", { className: cn('w-[132px] text-fds-xs font-fds-bold leading-fds-body', tone), "data-test-id": `${dataTestId}-idle-subtitle`, children: t(mergedLabels.idleSubtitle) })] })] }));
|
|
236
268
|
}
|
|
@@ -238,17 +270,27 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
|
|
|
238
270
|
};
|
|
239
271
|
const renderForbidden = () => {
|
|
240
272
|
const IconCmp = iconMap[DEFAULT_ICONS.forbidden];
|
|
241
|
-
if (!isSquare) {
|
|
273
|
+
if (!isSquare && !isSquareLg) {
|
|
242
274
|
return (_jsxs(_Fragment, { children: [_jsx(IconCmp, { size: "md", className: "shrink-0 text-fds-red-30", "aria-hidden": true, "data-test-id": `${dataTestId}-forbidden-icon` }), _jsxs("div", { className: "flex min-w-0 flex-col gap-fds-xs text-start", "aria-live": "polite", children: [_jsx("span", { className: "w-[130px] text-fds-sm font-fds-regular leading-fds-body text-fds-red-30", "data-test-id": `${dataTestId}-forbidden-title`, children: t(fbCopy?.title ?? '') }), _jsx("span", { className: "w-[132px] text-fds-xs font-fds-bold leading-fds-body text-fds-red-30", "data-test-id": `${dataTestId}-forbidden-subtitle`, children: t(fbCopy?.subtitle ?? '') })] })] }));
|
|
243
275
|
}
|
|
244
276
|
return (_jsxs(_Fragment, { children: [_jsx(IconCmp, { size: "lg", className: "text-fds-red-30", "aria-hidden": true, "data-test-id": `${dataTestId}-forbidden-icon` }), _jsx("span", { className: "text-fds-base font-fds-regular leading-fds-body text-fds-red-30", "data-test-id": `${dataTestId}-forbidden-compact`, children: t(mergedLabels.idleCompact) })] }));
|
|
245
277
|
};
|
|
246
278
|
const renderUploading = () => {
|
|
247
279
|
const SpinnerIcon = iconMap[DEFAULT_ICONS.uploading];
|
|
280
|
+
// Square-lg: 40px spinner + title + subtitle, centered vertically with 16px gap (Figma spec).
|
|
281
|
+
if (isSquareLg) {
|
|
282
|
+
return (_jsxs("div", { className: "flex flex-col items-center justify-center gap-fds-lg text-center", "data-test-id": `${dataTestId}-uploading-indicator`, children: [_jsx(SpinnerIcon, { className: "w-10 h-10 animate-spin text-fds-blue-30 rtl:direction-[reverse]", role: "status", "aria-label": t(mergedLabels.uploading) }), _jsxs("div", { className: "flex w-[190px] flex-col items-center", children: [_jsx("span", { className: "text-fds-h6 font-fds-bold leading-fds-title text-fds-gray-80 dark:text-fds-gray-20", children: t(mergedLabels.uploadingTitle) }), _jsx("span", { className: "text-fds-xs font-fds-regular leading-fds-body text-fds-gray-80 dark:text-fds-gray-20", children: t(mergedLabels.uploadingSubtitle) })] })] }));
|
|
283
|
+
}
|
|
284
|
+
// Parts F + H: spinner-only — text is NEVER rendered here (Rule 1 / Part H square rule).
|
|
248
285
|
if (uploadingStyle === 'spinner') {
|
|
249
286
|
return (_jsx(SpinnerIcon, { size: "lg", role: "status", "aria-label": t(mergedLabels.uploading), className: "animate-spin text-fds-blue-30 rtl:direction-[reverse]", "data-test-id": `${dataTestId}-uploading-indicator` }));
|
|
250
287
|
}
|
|
251
|
-
|
|
288
|
+
// Parts E + G: progress bar.
|
|
289
|
+
// Fill uses absolute+start-0 so it anchors to the logical-start edge — left in LTR,
|
|
290
|
+
// right in RTL — giving correct fill direction in both reading modes.
|
|
291
|
+
// The existing [dir="rtl"] .upload-progress-bar keyframe swap in globals.css covers
|
|
292
|
+
// the indeterminate animation direction automatically.
|
|
293
|
+
return (_jsxs("div", { className: cn('flex flex-col', isSquare ? 'w-[78px] gap-[13px]' : 'w-full gap-fds-sm'), "data-test-id": `${dataTestId}-uploading-indicator`, children: [_jsx("span", { className: cn('font-fds-regular leading-fds-body text-fds-gray-80 dark:text-fds-gray-20', isSquare ? 'w-[78px] text-fds-base' : 'w-[67px] text-fds-sm'), children: t(mergedLabels.uploading) }), _jsx("div", { className: cn('relative h-0.5 overflow-hidden rounded-[6px] bg-fds-gray-20 dark:bg-fds-gray-70', isSquare ? 'w-[78px]' : 'w-[165px]'), role: "progressbar", "aria-valuenow": uploadProgress !== null ? Math.round(uploadProgress * 100) : undefined, "aria-valuemin": uploadProgress !== null ? 0 : undefined, "aria-valuemax": uploadProgress !== null ? 100 : undefined, "aria-label": t(mergedLabels.uploading), children: _jsx("div", { className: cn('absolute inset-y-0 start-0 rounded-[6px] bg-fds-green-30 upload-progress-bar', uploadProgress === null
|
|
252
294
|
? 'w-1/2 animate-[uploading-progress_1.4s_ease-in-out_infinite]'
|
|
253
295
|
: 'transition-[width] duration-200 ease-in-out'), style: uploadProgress !== null ? { width: `${uploadProgress * 100}%` } : undefined }) })] }));
|
|
254
296
|
};
|
|
@@ -257,7 +299,7 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
|
|
|
257
299
|
? 'text-fds-blue-30'
|
|
258
300
|
: tone === 'error'
|
|
259
301
|
? 'text-fds-red-30'
|
|
260
|
-
: 'text-fds-gray-
|
|
302
|
+
: 'text-fds-gray-50 dark:text-fds-gray-50';
|
|
261
303
|
const accent = tone === 'success'
|
|
262
304
|
? 'text-fds-green-30'
|
|
263
305
|
: tone === 'error'
|
|
@@ -265,15 +307,35 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
|
|
|
265
307
|
: 'text-fds-gray-30 dark:text-fds-gray-50';
|
|
266
308
|
const StatusIcon = iconMap[tone === 'error' ? DEFAULT_ICONS.error : DEFAULT_ICONS.success];
|
|
267
309
|
const RemoveIcon = iconMap[DEFAULT_ICONS.remove];
|
|
268
|
-
const { display, tooltip } = buildFileLabel(allFiles, isSquare);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
310
|
+
const { display, tooltip } = buildFileLabel(allFiles, isSquare || isSquareLg);
|
|
311
|
+
// square-lg success (Figma Frame 6): 237×123 column, 8px gap between four children —
|
|
312
|
+
// 40px green check-circled icon (Semantic Green 30 #47A96E)
|
|
313
|
+
// file names: 184×18, 12px/18px Regular, Primary Blue 30 (#2075BD), centered
|
|
314
|
+
// "Hang on..." title: H6 16px/24px Bold (Gray 80)
|
|
315
|
+
// "We're uploading all your details" subtitle: 12px/18px Regular (Gray 80)
|
|
316
|
+
if (isSquareLg && tone === 'success') {
|
|
317
|
+
return (_jsxs("div", { className: "flex w-[237px] flex-col items-center gap-fds-sm text-center", children: [_jsx(StatusIcon, { className: "w-10 h-10 shrink-0 text-fds-green-30", "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` }), _jsx("span", { className: "w-[184px] truncate text-fds-xs font-fds-regular leading-fds-body text-fds-blue-30 cursor-default", title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), _jsx("span", { className: "w-[190px] text-fds-h6 font-fds-bold leading-fds-title text-fds-gray-80 dark:text-fds-gray-20", children: t(mergedLabels.uploadingTitle) }), _jsx("span", { className: "w-[190px] text-fds-xs font-fds-regular leading-fds-body text-fds-gray-80 dark:text-fds-gray-20", children: t(mergedLabels.uploadingSubtitle) })] }));
|
|
318
|
+
}
|
|
319
|
+
// square-lg disabled (filled): mirrors success layout but muted —
|
|
320
|
+
// 40px gray check-circled icon, gray filenames, gray "Hang on..." title + subtitle.
|
|
321
|
+
// Solid gray border + flat gray background come from the cva 'disabled' state.
|
|
322
|
+
if (isSquareLg && tone === 'disabled') {
|
|
323
|
+
return (_jsxs("div", { className: "flex w-[237px] flex-col items-center gap-fds-sm text-center", children: [_jsx(StatusIcon, { className: "w-10 h-10 shrink-0 text-fds-gray-50 dark:text-fds-gray-50", "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` }), _jsx("span", { className: "w-[184px] truncate text-fds-xs font-fds-regular leading-fds-body text-fds-gray-50 cursor-default dark:text-fds-gray-50", title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), _jsx("span", { className: "w-[190px] text-fds-h6 font-fds-bold leading-fds-title text-fds-gray-50 dark:text-fds-gray-50", children: t(mergedLabels.uploadingTitle) }), _jsx("span", { className: "w-[190px] text-fds-xs font-fds-regular leading-fds-body text-fds-gray-50 dark:text-fds-gray-50", children: t(mergedLabels.uploadingSubtitle) })] }));
|
|
324
|
+
}
|
|
325
|
+
// square-lg error: large icon, truncated filenames with native tooltip, error text block
|
|
326
|
+
if (isSquareLg && tone === 'error') {
|
|
327
|
+
return (_jsxs(_Fragment, { children: [_jsx(StatusIcon, { size: "lg", className: "shrink-0 text-fds-red-30", "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` }), _jsx("span", { className: "w-full truncate text-center text-fds-xs font-fds-regular leading-fds-body text-fds-blue-30 cursor-default", title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), _jsxs("div", { className: "flex w-full flex-col items-center gap-fds-xs text-center", "aria-live": "polite", children: [_jsx("span", { className: "text-fds-h6 font-fds-bold leading-fds-title text-fds-gray-80 dark:text-fds-gray-20", children: t('Error') }), _jsx("span", { className: "text-fds-xs font-fds-regular leading-fds-body text-fds-gray-80 dark:text-fds-gray-20", children: t('We were unable to read your information') }), _jsx("span", { className: "pointer-events-auto cursor-pointer! text-fds-xs font-fds-regular leading-fds-body text-fds-blue-30 underline", children: t('Try again by selecting another file') })] })] }));
|
|
328
|
+
}
|
|
329
|
+
const isSquareAny = isSquare || isSquareLg;
|
|
330
|
+
const statusIconNode = isSquareAny
|
|
274
331
|
? _jsx(StatusIcon, { size: "lg", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })
|
|
275
|
-
: _jsx(StatusIcon, { size: "
|
|
276
|
-
|
|
332
|
+
: _jsx(StatusIcon, { size: "xs", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` });
|
|
333
|
+
if (isSquareAny) {
|
|
334
|
+
return (_jsxs(_Fragment, { children: [statusIconNode, _jsxs("div", { className: "flex items-center gap-fds-xs", children: [_jsx("span", { className: cn('min-w-0 truncate font-fds-regular leading-fds-body text-fds-sm', tokenColor), title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), tone !== 'disabled' && (_jsx("button", { type: "button", onClick: handleRemoveClick, "aria-label": t('Remove all files'), "data-test-id": `${dataTestId}-remove`, className: "inline-flex size-4 shrink-0 items-center justify-center rounded-fds-sm focus-visible:outline-none focus-visible:ring-2 cursor-pointer text-fds-red-30 hover:bg-fds-red-05 focus-visible:ring-fds-red-30 dark:hover:bg-fds-red-30/10", children: _jsx(RemoveIcon, { size: "sm", "aria-hidden": true }) }))] })] }));
|
|
335
|
+
}
|
|
336
|
+
return (_jsxs(_Fragment, { children: [statusIconNode, _jsx("span", { className: cn('truncate font-fds-regular leading-fds-body',
|
|
337
|
+
// Square error/disabled: 14px/21px LH per Parts L/N. Square success/others: 12px/18px (text-fds-sm).
|
|
338
|
+
isSquareAny && (tone === 'error' || tone === 'disabled') ? 'text-fds-base' : 'text-fds-sm', !isSquareAny && !isFillContainer && 'min-w-0 flex-1', !isSquareAny && isFillContainer && 'min-w-0', tokenColor, isSquareAny && 'w-full text-center'), title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), !isSquareAny && hasFiles && tone === 'disabled' && (_jsx("span", { "aria-hidden": true, "data-test-id": `${dataTestId}-remove-disabled`, className: cn('inline-flex size-4 shrink-0 items-center justify-center text-fds-gray-50 dark:text-fds-gray-50', !isFillContainer && 'ms-auto'), children: _jsx(RemoveIcon, { size: "sm", "aria-hidden": true }) })), !isSquareAny && hasFiles && tone !== 'disabled' && (_jsx("button", { type: "button", onClick: handleRemoveClick, "aria-label": t('Remove all files'), "data-test-id": `${dataTestId}-remove`, className: cn('inline-flex size-4 shrink-0 items-center justify-center rounded-fds-sm focus-visible:outline-none focus-visible:ring-2 cursor-pointer text-fds-red-30 hover:bg-fds-red-05 focus-visible:ring-fds-red-30 dark:hover:bg-fds-red-30/10', !isFillContainer && 'ms-auto'), children: _jsx(RemoveIcon, { size: "sm", "aria-hidden": true }) }))] }));
|
|
277
339
|
};
|
|
278
340
|
let body = null;
|
|
279
341
|
switch (effectiveState) {
|
|
@@ -302,17 +364,37 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
|
|
|
302
364
|
effectiveState === 'success' ||
|
|
303
365
|
effectiveState === 'error' ||
|
|
304
366
|
(effectiveState === 'disabled' && hasFiles);
|
|
305
|
-
|
|
367
|
+
// All rectangle states share the same unified 198×69px frame (Figma spec, 2026-05-06).
|
|
368
|
+
const frameSize = (isSquare || isSquareLg)
|
|
306
369
|
? ''
|
|
307
370
|
: isFillContainer
|
|
308
|
-
? isFilled ? 'h-full w-full justify-center' : 'h-full w-full'
|
|
309
|
-
:
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
371
|
+
? (isFilled ? 'h-full w-full justify-center' : 'h-full w-full')
|
|
372
|
+
: 'h-[69px] w-[198px]';
|
|
373
|
+
const labelNode = (_jsxs("label", { ref: ref, htmlFor: inputId, "data-test-id": dataTestId, "data-state": effectiveState, "data-variant": variant, "data-filled": hasFiles ? 'true' : 'false', dir: dir, "aria-label": ariaLabel ?? t(mergedLabels.idleTitle), "aria-disabled": isDisabled || undefined, "aria-busy": (effectiveState === 'uploading' || effectiveState === 'uploading-spinner') || undefined, className: cn(uploadVariants({ variant, state: effectiveState }),
|
|
374
|
+
// Default state: fluid gradient border for fill-container (scales with element),
|
|
375
|
+
// fixed SVG border otherwise (pixel-exact per Figma spec).
|
|
376
|
+
// Square: pixel-perfect 100×99 SVG — identical stroke on all four sides, no corner overlap.
|
|
377
|
+
// Rectangle: fixed SVG at spec dimensions, fluid gradient when stretching to fill container.
|
|
378
|
+
effectiveState === 'default' && (isSquareLg ? 'upload-dash-4-2-square-lg' :
|
|
379
|
+
isSquare ? 'upload-dash-4-2-square' :
|
|
380
|
+
isFillContainer ? 'upload-dash-fluid' : 'upload-dash-4-2'), effectiveState === 'forbidden' && (isSquareLg ? 'upload-dash-fluid-red' :
|
|
381
|
+
isSquare ? 'upload-dash-4-4' :
|
|
382
|
+
isFillContainer ? 'upload-dash-fluid-red' : 'upload-dash-4-2-red'), (effectiveState === 'uploading' || effectiveState === 'uploading-spinner') && isSquareLg && 'border-0 upload-dash-4-2-square-lg',
|
|
383
|
+
// square-lg success: dashed gray border per Figma (overrides solid blue from cva)
|
|
384
|
+
effectiveState === 'success' && isSquareLg && 'border-0 upload-dash-4-2-square-lg',
|
|
385
|
+
// square-lg error: gray border (Figma spec) + allow retry clicks
|
|
386
|
+
effectiveState === 'error' && isSquareLg && 'border-0 upload-dash-4-2-square-lg cursor-pointer!', effectiveState === 'uploading-spinner' && !isSquare && 'py-3.5!', frameSize, className), onDragEnter: handleDragEnter, onDragOver: (e) => e.preventDefault(), onDragLeave: handleDragLeave, onDrop: handleDrop, ...rest, children: [_jsx("input", { id: inputId, type: "file", accept: acceptAttr, multiple: isMultiple, disabled: isDisabled, className: "sr-only", onChange: handleNativeChange, "data-test-id": `${dataTestId}-input` }), body] }));
|
|
387
|
+
if (!isMultiFileList) {
|
|
388
|
+
return labelNode;
|
|
389
|
+
}
|
|
390
|
+
const RowCheckIcon = iconMap[DEFAULT_ICONS.success];
|
|
391
|
+
const RowTrashIcon = iconMap[DEFAULT_ICONS.remove];
|
|
392
|
+
return (_jsxs("div", { className: cn('flex flex-col gap-fds-sm', isFillContainer ? 'w-full' : 'w-[198px]'), "data-test-id": `${dataTestId}-list`, dir: dir, children: [labelNode, _jsx("ul", { className: "flex flex-col", "data-test-id": `${dataTestId}-files`, children: allFiles.map((f, idx) => (_jsxs("li", { className: "flex items-center gap-fds-sm border-b border-fds-gray-20 py-fds-sm last:border-b-0 dark:border-fds-gray-70", "data-test-id": `${dataTestId}-file-${idx}`, children: [_jsx(RowCheckIcon, { size: "sm", className: "shrink-0 text-fds-green-30", "aria-hidden": true }), _jsx("span", { className: "min-w-0 flex-1 truncate text-fds-sm font-fds-regular leading-fds-body text-fds-blue-30", title: f.name, "data-test-id": `${dataTestId}-file-${idx}-name`, children: f.name }), _jsx("button", { type: "button", onClick: (e) => {
|
|
393
|
+
e.preventDefault();
|
|
394
|
+
e.stopPropagation();
|
|
395
|
+
onRemove?.(idx);
|
|
396
|
+
}, "aria-label": t('Remove file'), "data-test-id": `${dataTestId}-file-${idx}-remove`, className: "inline-flex size-4 shrink-0 items-center justify-center rounded-fds-sm focus-visible:outline-none focus-visible:ring-2 cursor-pointer text-fds-red-30 hover:bg-fds-red-05 focus-visible:ring-fds-red-30 dark:hover:bg-fds-red-30/10", children: _jsx(RowTrashIcon, { size: "sm", "aria-hidden": true }) })] }, `${f.name}-${idx}`))) })] }));
|
|
317
397
|
});
|
|
318
398
|
export { uploadVariants };
|
|
399
|
+
/** Alias for `Upload` enforcing single-file semantics. Use instead of `Upload` for all new code. */
|
|
400
|
+
export const SingleFileUpload = Upload;
|
package/dist/esm/index.js
CHANGED
|
@@ -3,4 +3,5 @@ export { cn } from './lib/utils';
|
|
|
3
3
|
export { useStableId } from './lib/use-stable-id';
|
|
4
4
|
export { iconMap, renderInputIcon } from './lib/icon-utils';
|
|
5
5
|
// Components
|
|
6
|
-
export { Upload, uploadVariants } from './components/upload';
|
|
6
|
+
export { Upload, uploadVariants, SingleFileUpload } from './components/upload';
|
|
7
|
+
export { MultipleFilesUpload } from './components/multiple-files-upload';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type HTMLAttributes } from 'react';
|
|
2
|
+
import type { UploadFile, ForbiddenReason, UploadTranslator } from './upload';
|
|
3
|
+
export type MultipleFileEntryStatus = 'success' | 'error';
|
|
4
|
+
export type MultipleFileEntry = {
|
|
5
|
+
file: UploadFile;
|
|
6
|
+
status: MultipleFileEntryStatus;
|
|
7
|
+
};
|
|
8
|
+
export interface MultipleFilesUploadProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
|
9
|
+
dataTestId: string;
|
|
10
|
+
files?: MultipleFileEntry[];
|
|
11
|
+
accept?: string[];
|
|
12
|
+
maxFiles?: number;
|
|
13
|
+
maxSize?: number;
|
|
14
|
+
isDisabled?: boolean;
|
|
15
|
+
isFillContainer?: boolean;
|
|
16
|
+
labels?: Partial<{
|
|
17
|
+
idleTitle: string;
|
|
18
|
+
idleSubtitle: string;
|
|
19
|
+
uploading: string;
|
|
20
|
+
forbidden: Partial<Record<ForbiddenReason, {
|
|
21
|
+
title: string;
|
|
22
|
+
subtitle: string;
|
|
23
|
+
}>>;
|
|
24
|
+
}>;
|
|
25
|
+
t?: UploadTranslator;
|
|
26
|
+
onUpload?: (files: File[], setIsUploading: (v: boolean) => void) => void;
|
|
27
|
+
onRemove?: (file: UploadFile) => void;
|
|
28
|
+
onForbidden?: (reason: ForbiddenReason) => void;
|
|
29
|
+
ariaLabel?: string;
|
|
30
|
+
defaultUploading?: boolean;
|
|
31
|
+
defaultForbidden?: ForbiddenReason;
|
|
32
|
+
}
|
|
33
|
+
export declare const MultipleFilesUpload: import("react").ForwardRefExoticComponent<MultipleFilesUploadProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { type HTMLAttributes } from 'react';
|
|
2
2
|
import { type VariantProps } from 'class-variance-authority';
|
|
3
|
-
export type UploadVariant = 'rectangle' | 'square';
|
|
3
|
+
export type UploadVariant = 'rectangle' | 'square' | 'square-lg';
|
|
4
4
|
export type ForbiddenReason = 'multiple' | 'type' | 'size' | 'extension' | 'custom';
|
|
5
5
|
export type UploadFile = File | {
|
|
6
6
|
name: string;
|
|
7
7
|
};
|
|
8
8
|
export type UploadTranslator = (key: string) => string;
|
|
9
9
|
declare const uploadVariants: (props?: ({
|
|
10
|
-
variant?: "square" | "rectangle" | null | undefined;
|
|
10
|
+
variant?: "square" | "rectangle" | "square-lg" | null | undefined;
|
|
11
11
|
state?: "default" | "disabled" | "forbidden" | "uploading" | "uploading-spinner" | "success" | "error" | null | undefined;
|
|
12
12
|
} & import("class-variance-authority/dist/types").ClassProp) | undefined) => string;
|
|
13
13
|
export interface UploadProps extends Omit<HTMLAttributes<HTMLLabelElement>, 'onChange'>, VariantProps<typeof uploadVariants> {
|
|
@@ -36,7 +36,10 @@ export interface UploadProps extends Omit<HTMLAttributes<HTMLLabelElement>, 'onC
|
|
|
36
36
|
idleTitle: string;
|
|
37
37
|
idleSubtitle: string;
|
|
38
38
|
idleCompact: string;
|
|
39
|
+
idleDragDrop: string;
|
|
39
40
|
uploading: string;
|
|
41
|
+
uploadingTitle: string;
|
|
42
|
+
uploadingSubtitle: string;
|
|
40
43
|
forbidden: Partial<Record<ForbiddenReason, {
|
|
41
44
|
title: string;
|
|
42
45
|
subtitle: string;
|
|
@@ -47,8 +50,8 @@ export interface UploadProps extends Omit<HTMLAttributes<HTMLLabelElement>, 'onC
|
|
|
47
50
|
t?: UploadTranslator;
|
|
48
51
|
/** Fires when one or more files are selected or dropped. The component flips to `uploading` after this fires; call `setIsUploading(false)` when your async upload completes. Pass an optional `progress` value (0–1) to drive the determinate progress bar; omit it for the indeterminate animation. */
|
|
49
52
|
onUpload?: (files: File[], setIsUploading: (v: boolean, progress?: number) => void) => void;
|
|
50
|
-
/** Fires when
|
|
51
|
-
onRemove?: () => void;
|
|
53
|
+
/** Fires when a trash button is clicked. Called with no argument from the single-slot trash (rectangle/square) — parent should clear all files. Called with the row index from the multi-file list view — parent should remove that one file. */
|
|
54
|
+
onRemove?: (index?: number) => void;
|
|
52
55
|
/** Fires when an invalid drag/drop is detected. */
|
|
53
56
|
onForbidden?: (reason: ForbiddenReason) => void;
|
|
54
57
|
/** A11y label override for the surface. */
|
|
@@ -64,3 +67,5 @@ export interface UploadProps extends Omit<HTMLAttributes<HTMLLabelElement>, 'onC
|
|
|
64
67
|
}
|
|
65
68
|
export declare const Upload: import("react").ForwardRefExoticComponent<UploadProps & import("react").RefAttributes<HTMLLabelElement>>;
|
|
66
69
|
export { uploadVariants };
|
|
70
|
+
/** Alias for `Upload` enforcing single-file semantics. Use instead of `Upload` for all new code. */
|
|
71
|
+
export declare const SingleFileUpload: import("react").ForwardRefExoticComponent<UploadProps & import("react").RefAttributes<HTMLLabelElement>>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -2,5 +2,7 @@ export { cn } from './lib/utils';
|
|
|
2
2
|
export { useStableId } from './lib/use-stable-id';
|
|
3
3
|
export { iconMap, renderInputIcon } from './lib/icon-utils';
|
|
4
4
|
export type { IconName, InputShellSize } from './lib/icon-utils';
|
|
5
|
-
export { Upload, uploadVariants } from './components/upload';
|
|
5
|
+
export { Upload, uploadVariants, SingleFileUpload } from './components/upload';
|
|
6
6
|
export type { UploadProps, UploadVariant, UploadFile, ForbiddenReason, UploadTranslator } from './components/upload';
|
|
7
|
+
export { MultipleFilesUpload } from './components/multiple-files-upload';
|
|
8
|
+
export type { MultipleFilesUploadProps, MultipleFileEntry, MultipleFileEntryStatus } from './components/multiple-files-upload';
|
package/package.json
CHANGED