@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.
@@ -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-sm p-fds-lg',
17
- square: 'h-[99px] w-[100px] flex-col items-center justify-center gap-[10px] px-[26px] py-[23px]',
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 effectiveState = isDisabled
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
- return ((0, jsx_runtime_1.jsxs)("div", { className: "flex w-full flex-col 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-full 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-full' : '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
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-30 dark:text-fds-gray-50';
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
- const statusIconNode = tone === 'success' ? (isSquare
274
- ? (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` })
275
- : (0, jsx_runtime_1.jsx)(StatusIcon, { className: (0, utils_1.cn)('shrink-0 w-[13.33px] h-[13.33px]', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })) : tone === 'error' ? (isSquare
276
- ? (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` })
277
- : (0, jsx_runtime_1.jsx)(StatusIcon, { size: "sm", className: (0, utils_1.cn)('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })) : tone === 'disabled' ? (isSquare
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: "sm", className: (0, utils_1.cn)('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })) : ((0, jsx_runtime_1.jsx)(StatusIcon, { size: isSquare ? 'md' : 'xs', className: (0, utils_1.cn)('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` }));
280
- 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', isSquare && (tone === 'error' || tone === 'disabled') ? 'text-fds-base' : 'text-fds-sm', !isSquare && !isFillContainer && 'min-w-0 flex-1', !isSquare && isFillContainer && 'min-w-0', tokenColor, isSquare && 'w-full text-center'), title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), !isSquare && 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-6 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 }) }))] }));
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
- const frameSize = isSquare
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
- : effectiveState === 'forbidden'
314
- ? 'h-[69px] w-[193px]'
315
- : isFilled
316
- ? 'h-[60px] w-[198px]'
317
- : 'h-[69px] w-[196px]';
318
- return ((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 }), effectiveState === 'default' && (isSquare ? 'upload-dash-4-2-square' : isFillContainer ? 'upload-dash-fluid' : 'upload-dash-4-2'), effectiveState === 'forbidden' && (isSquare
319
- ? 'h-[100px] gap-[4px] upload-dash-4-4'
320
- : isFillContainer ? 'upload-dash-fluid-red' : 'upload-dash-4-2-red'), (effectiveState === 'uploading' || effectiveState === 'uploading-spinner') && isSquare && 'h-[100px] p-[11px]!', effectiveState === 'success' && isSquare && 'h-[100px] gap-[4px]', effectiveState === 'error' && isSquare && 'h-[100px] gap-[4px] p-[11px]!', effectiveState === 'disabled' && isSquare && hasFiles && 'h-[100px] gap-[4px] p-[11px]!', 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] }));
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, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
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-sm p-fds-lg',
14
- square: 'h-[99px] w-[100px] flex-col items-center justify-center gap-[10px] px-[26px] py-[23px]',
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 effectiveState = isDisabled
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
- return (_jsxs("div", { className: "flex w-full flex-col 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-full 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-full' : '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
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-30 dark:text-fds-gray-50';
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
- const statusIconNode = tone === 'success' ? (isSquare
270
- ? _jsx(StatusIcon, { size: "lg", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })
271
- : _jsx(StatusIcon, { className: cn('shrink-0 w-[13.33px] h-[13.33px]', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })) : tone === 'error' ? (isSquare
272
- ? _jsx(StatusIcon, { size: "lg", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })
273
- : _jsx(StatusIcon, { size: "sm", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })) : tone === 'disabled' ? (isSquare
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: "sm", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })) : (_jsx(StatusIcon, { size: isSquare ? 'md' : 'xs', className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` }));
276
- return (_jsxs(_Fragment, { children: [statusIconNode, _jsx("span", { className: cn('truncate font-fds-regular leading-fds-body', isSquare && (tone === 'error' || tone === 'disabled') ? 'text-fds-base' : 'text-fds-sm', !isSquare && !isFillContainer && 'min-w-0 flex-1', !isSquare && isFillContainer && 'min-w-0', tokenColor, isSquare && 'w-full text-center'), title: tooltip, "data-test-id": `${dataTestId}-filename`, children: display }), !isSquare && 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-6 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 }) }))] }));
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
- const frameSize = isSquare
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
- : effectiveState === 'forbidden'
310
- ? 'h-[69px] w-[193px]'
311
- : isFilled
312
- ? 'h-[60px] w-[198px]'
313
- : 'h-[69px] w-[196px]';
314
- return (_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 }), effectiveState === 'default' && (isSquare ? 'upload-dash-4-2-square' : isFillContainer ? 'upload-dash-fluid' : 'upload-dash-4-2'), effectiveState === 'forbidden' && (isSquare
315
- ? 'h-[100px] gap-[4px] upload-dash-4-4'
316
- : isFillContainer ? 'upload-dash-fluid-red' : 'upload-dash-4-2-red'), (effectiveState === 'uploading' || effectiveState === 'uploading-spinner') && isSquare && 'h-[100px] p-[11px]!', effectiveState === 'success' && isSquare && 'h-[100px] gap-[4px]', effectiveState === 'error' && isSquare && 'h-[100px] gap-[4px] p-[11px]!', effectiveState === 'disabled' && isSquare && hasFiles && 'h-[100px] gap-[4px] p-[11px]!', 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] }));
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 the trash button is clicked. Removes ALL files. */
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>>;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freightos/freightwind",
3
- "version": "2.1.4",
3
+ "version": "2.1.7",
4
4
  "private": false,
5
5
  "description": "FreightWind Design System — icons, constants, and utilities for Freightos applications",
6
6
  "main": "./dist/cjs/index.js",