@freightos/freightwind 2.1.6 → 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.
@@ -10,12 +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
- 'square-lg': 'h-[238px] w-[285px] flex-col items-center justify-center gap-fds-lg p-fds-xl',
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`,
19
21
  },
20
22
  state: {
21
23
  default: 'cursor-pointer justify-center bg-fds-gray-10 dark:bg-fds-gray-90',
@@ -36,6 +38,8 @@ const DEFAULT_LABELS = {
36
38
  idleCompact: 'Upload',
37
39
  idleDragDrop: 'Drag and drop your file',
38
40
  uploading: 'Uploading…',
41
+ uploadingTitle: 'Hang on...',
42
+ uploadingSubtitle: 'We’re uploading all your details',
39
43
  forbidden: {
40
44
  multiple: { title: 'Oops!', subtitle: 'You can only drag one file', compact: 'Oops!' },
41
45
  type: { title: 'Oops!', subtitle: 'Unsupported file format', compact: 'Oops!' },
@@ -123,6 +127,14 @@ function validateDragItems(items, { maxFiles, accept, alreadyHeld = 0 }) {
123
127
  }
124
128
  return { ok: true };
125
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
+ */
126
138
  function validatePickedFile(file, { accept, maxSize }) {
127
139
  if (!(0, file_extensions_1.hasValidExtension)(file.name))
128
140
  return { ok: false, reason: 'extension' };
@@ -147,7 +159,7 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
147
159
  const fbCopy = mergedForbidden[forbiddenReason ?? 'multiple'];
148
160
  const allFiles = filesProp ?? (value ? [value] : []);
149
161
  const hasFiles = allFiles.length > 0;
150
- const effectiveState = isDisabled
162
+ const baseEffectiveState = isDisabled
151
163
  ? 'disabled'
152
164
  : forbiddenReason
153
165
  ? 'forbidden'
@@ -160,9 +172,22 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
160
172
  : 'default';
161
173
  const isSquare = variant === 'square';
162
174
  const isSquareLg = variant === 'square-lg';
163
- const isSquareAny = isSquare || isSquareLg;
175
+ const isRectangle = !isSquare && !isSquareLg;
164
176
  const isMultiple = maxFiles == null || maxFiles > 1;
165
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;
166
191
  const triggerUpload = (selected) => {
167
192
  if (selected.length === 0)
168
193
  return;
@@ -234,13 +259,13 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
234
259
  onRemove?.();
235
260
  };
236
261
  const renderIdle = () => {
237
- const idleIconName = isSquareAny ? DEFAULT_ICONS.idleSquare : DEFAULT_ICONS.idleRectangle;
262
+ const idleIconName = (isSquare || isSquareLg) ? DEFAULT_ICONS.idleSquare : DEFAULT_ICONS.idleRectangle;
238
263
  const IconCmp = icon_utils_1.iconMap[idleIconName];
239
264
  const tone = effectiveState === 'disabled'
240
265
  ? 'text-fds-gray-50 dark:text-fds-gray-50'
241
266
  : 'text-fds-gray-80 dark:text-fds-gray-20';
242
267
  if (isSquareLg) {
243
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(IconCmp, { className: (0, utils_1.cn)('w-[40px] h-[40px]', 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) })] }));
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) })] }));
244
269
  }
245
270
  if (!isSquare) {
246
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) })] })] }));
@@ -249,17 +274,27 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
249
274
  };
250
275
  const renderForbidden = () => {
251
276
  const IconCmp = icon_utils_1.iconMap[DEFAULT_ICONS.forbidden];
252
- if (!isSquareAny) {
277
+ if (!isSquare && !isSquareLg) {
253
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 ?? '') })] })] }));
254
279
  }
255
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) })] }));
256
281
  };
257
282
  const renderUploading = () => {
258
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).
259
289
  if (uploadingStyle === 'spinner') {
260
- return isSquareLg ? ((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), "data-test-id": `${dataTestId}-uploading-indicator` })) : ((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` }));
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` }));
261
291
  }
262
- 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', isSquareAny ? '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', isSquareAny ? '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
263
298
  ? 'w-1/2 animate-[uploading-progress_1.4s_ease-in-out_infinite]'
264
299
  : 'transition-[width] duration-200 ease-in-out'), style: uploadProgress !== null ? { width: `${uploadProgress * 100}%` } : undefined }) })] }));
265
300
  };
@@ -268,7 +303,7 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
268
303
  ? 'text-fds-blue-30'
269
304
  : tone === 'error'
270
305
  ? 'text-fds-red-30'
271
- : 'text-fds-gray-30 dark:text-fds-gray-50';
306
+ : 'text-fds-gray-50 dark:text-fds-gray-50';
272
307
  const accent = tone === 'success'
273
308
  ? 'text-fds-green-30'
274
309
  : tone === 'error'
@@ -276,19 +311,35 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
276
311
  : 'text-fds-gray-30 dark:text-fds-gray-50';
277
312
  const StatusIcon = icon_utils_1.iconMap[tone === 'error' ? DEFAULT_ICONS.error : DEFAULT_ICONS.success];
278
313
  const RemoveIcon = icon_utils_1.iconMap[DEFAULT_ICONS.remove];
279
- const { display, tooltip } = buildFileLabel(allFiles, isSquareAny);
280
- // square-lg error: special layout with large icon, truncated filenames + tooltip, and error text block
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
281
330
  if (isSquareLg && tone === 'error') {
282
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(StatusIcon, { className: "w-10 h-10 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') })] })] }));
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') })] })] }));
283
332
  }
284
- const statusIconNode = tone === 'success' ? (isSquareAny
333
+ const isSquareAny = isSquare || isSquareLg;
334
+ const statusIconNode = isSquareAny
285
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` })
286
- : (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' ? (isSquareAny
287
- ? (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` })
288
- : (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' ? (isSquareAny
289
- ? (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` })
290
- : (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: isSquareAny ? 'md' : 'xs', className: (0, utils_1.cn)('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` }));
291
- 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', 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)("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 }) }))] }));
292
343
  };
293
344
  let body = null;
294
345
  switch (effectiveState) {
@@ -317,17 +368,36 @@ exports.Upload = (0, react_1.forwardRef)(function Upload({ dataTestId, variant =
317
368
  effectiveState === 'success' ||
318
369
  effectiveState === 'error' ||
319
370
  (effectiveState === 'disabled' && hasFiles);
320
- const frameSize = isSquareAny
371
+ // All rectangle states share the same unified 198×69px frame (Figma spec, 2026-05-06).
372
+ const frameSize = (isSquare || isSquareLg)
321
373
  ? ''
322
374
  : isFillContainer
323
375
  ? (isFilled ? 'h-full w-full justify-center' : 'h-full w-full')
324
376
  : 'h-[69px] w-[198px]';
325
- 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' && (isSquareLg ? 'upload-dash-fluid' :
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' :
326
383
  isSquare ? 'upload-dash-4-2-square' :
327
384
  isFillContainer ? 'upload-dash-fluid' : 'upload-dash-4-2'), effectiveState === 'forbidden' && (isSquareLg ? 'upload-dash-fluid-red' :
328
- isSquare ? 'h-[100px] gap-[4px] upload-dash-4-4' :
329
- isFillContainer ? 'upload-dash-fluid-red' : 'upload-dash-4-2-red'), (effectiveState === 'uploading' || effectiveState === 'uploading-spinner') && isSquare && 'h-[100px] p-[11px]!', (effectiveState === 'uploading' || effectiveState === 'uploading-spinner') && isSquareLg && 'h-[238px]', effectiveState === 'success' && isSquare && 'h-[100px] gap-[4px]', effectiveState === 'error' && isSquare && 'h-[100px] gap-[4px] p-[11px]!',
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',
330
389
  // square-lg error: gray border (Figma spec) + allow retry clicks
331
- effectiveState === 'error' && isSquareLg && 'border-fds-gray-30! dark:border-fds-gray-70! cursor-pointer!', effectiveState === 'disabled' && isSquare && hasFiles && 'h-[100px] gap-[4px] p-[11px]!', effectiveState === 'uploading-spinner' && !isSquareAny && '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] }));
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}`))) })] }));
332
401
  });
402
+ /** Alias for `Upload` enforcing single-file semantics. Use instead of `Upload` for all new code. */
333
403
  exports.SingleFileUpload = exports.Upload;
@@ -7,12 +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
- 'square-lg': 'h-[238px] w-[285px] flex-col items-center justify-center gap-fds-lg p-fds-xl',
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`,
16
18
  },
17
19
  state: {
18
20
  default: 'cursor-pointer justify-center bg-fds-gray-10 dark:bg-fds-gray-90',
@@ -32,6 +34,8 @@ const DEFAULT_LABELS = {
32
34
  idleCompact: 'Upload',
33
35
  idleDragDrop: 'Drag and drop your file',
34
36
  uploading: 'Uploading…',
37
+ uploadingTitle: 'Hang on...',
38
+ uploadingSubtitle: 'We’re uploading all your details',
35
39
  forbidden: {
36
40
  multiple: { title: 'Oops!', subtitle: 'You can only drag one file', compact: 'Oops!' },
37
41
  type: { title: 'Oops!', subtitle: 'Unsupported file format', compact: 'Oops!' },
@@ -119,6 +123,14 @@ function validateDragItems(items, { maxFiles, accept, alreadyHeld = 0 }) {
119
123
  }
120
124
  return { ok: true };
121
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
+ */
122
134
  function validatePickedFile(file, { accept, maxSize }) {
123
135
  if (!hasValidExtension(file.name))
124
136
  return { ok: false, reason: 'extension' };
@@ -143,7 +155,7 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
143
155
  const fbCopy = mergedForbidden[forbiddenReason ?? 'multiple'];
144
156
  const allFiles = filesProp ?? (value ? [value] : []);
145
157
  const hasFiles = allFiles.length > 0;
146
- const effectiveState = isDisabled
158
+ const baseEffectiveState = isDisabled
147
159
  ? 'disabled'
148
160
  : forbiddenReason
149
161
  ? 'forbidden'
@@ -156,9 +168,22 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
156
168
  : 'default';
157
169
  const isSquare = variant === 'square';
158
170
  const isSquareLg = variant === 'square-lg';
159
- const isSquareAny = isSquare || isSquareLg;
171
+ const isRectangle = !isSquare && !isSquareLg;
160
172
  const isMultiple = maxFiles == null || maxFiles > 1;
161
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;
162
187
  const triggerUpload = (selected) => {
163
188
  if (selected.length === 0)
164
189
  return;
@@ -230,13 +255,13 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
230
255
  onRemove?.();
231
256
  };
232
257
  const renderIdle = () => {
233
- const idleIconName = isSquareAny ? DEFAULT_ICONS.idleSquare : DEFAULT_ICONS.idleRectangle;
258
+ const idleIconName = (isSquare || isSquareLg) ? DEFAULT_ICONS.idleSquare : DEFAULT_ICONS.idleRectangle;
234
259
  const IconCmp = iconMap[idleIconName];
235
260
  const tone = effectiveState === 'disabled'
236
261
  ? 'text-fds-gray-50 dark:text-fds-gray-50'
237
262
  : 'text-fds-gray-80 dark:text-fds-gray-20';
238
263
  if (isSquareLg) {
239
- return (_jsxs(_Fragment, { children: [_jsx(IconCmp, { className: cn('w-[40px] h-[40px]', 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) })] }));
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) })] }));
240
265
  }
241
266
  if (!isSquare) {
242
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) })] })] }));
@@ -245,17 +270,27 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
245
270
  };
246
271
  const renderForbidden = () => {
247
272
  const IconCmp = iconMap[DEFAULT_ICONS.forbidden];
248
- if (!isSquareAny) {
273
+ if (!isSquare && !isSquareLg) {
249
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 ?? '') })] })] }));
250
275
  }
251
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) })] }));
252
277
  };
253
278
  const renderUploading = () => {
254
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).
255
285
  if (uploadingStyle === 'spinner') {
256
- return isSquareLg ? (_jsx(SpinnerIcon, { className: "w-10 h-10 animate-spin text-fds-blue-30 rtl:direction-[reverse]", role: "status", "aria-label": t(mergedLabels.uploading), "data-test-id": `${dataTestId}-uploading-indicator` })) : (_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` }));
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` }));
257
287
  }
258
- 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', isSquareAny ? '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', isSquareAny ? '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
259
294
  ? 'w-1/2 animate-[uploading-progress_1.4s_ease-in-out_infinite]'
260
295
  : 'transition-[width] duration-200 ease-in-out'), style: uploadProgress !== null ? { width: `${uploadProgress * 100}%` } : undefined }) })] }));
261
296
  };
@@ -264,7 +299,7 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
264
299
  ? 'text-fds-blue-30'
265
300
  : tone === 'error'
266
301
  ? 'text-fds-red-30'
267
- : 'text-fds-gray-30 dark:text-fds-gray-50';
302
+ : 'text-fds-gray-50 dark:text-fds-gray-50';
268
303
  const accent = tone === 'success'
269
304
  ? 'text-fds-green-30'
270
305
  : tone === 'error'
@@ -272,19 +307,35 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
272
307
  : 'text-fds-gray-30 dark:text-fds-gray-50';
273
308
  const StatusIcon = iconMap[tone === 'error' ? DEFAULT_ICONS.error : DEFAULT_ICONS.success];
274
309
  const RemoveIcon = iconMap[DEFAULT_ICONS.remove];
275
- const { display, tooltip } = buildFileLabel(allFiles, isSquareAny);
276
- // square-lg error: special layout with large icon, truncated filenames + tooltip, and error text block
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
277
326
  if (isSquareLg && tone === 'error') {
278
- return (_jsxs(_Fragment, { children: [_jsx(StatusIcon, { className: "w-10 h-10 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') })] })] }));
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') })] })] }));
279
328
  }
280
- const statusIconNode = tone === 'success' ? (isSquareAny
329
+ const isSquareAny = isSquare || isSquareLg;
330
+ const statusIconNode = isSquareAny
281
331
  ? _jsx(StatusIcon, { size: "lg", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })
282
- : _jsx(StatusIcon, { className: cn('shrink-0 w-[13.33px] h-[13.33px]', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })) : tone === 'error' ? (isSquareAny
283
- ? _jsx(StatusIcon, { size: "lg", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })
284
- : _jsx(StatusIcon, { size: "sm", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })) : tone === 'disabled' ? (isSquareAny
285
- ? _jsx(StatusIcon, { size: "lg", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })
286
- : _jsx(StatusIcon, { size: "sm", className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` })) : (_jsx(StatusIcon, { size: isSquareAny ? 'md' : 'xs', className: cn('shrink-0', accent), "aria-hidden": true, "data-test-id": `${dataTestId}-status-icon` }));
287
- return (_jsxs(_Fragment, { children: [statusIconNode, _jsx("span", { className: cn('truncate font-fds-regular leading-fds-body', 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("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 }) }))] }));
288
339
  };
289
340
  let body = null;
290
341
  switch (effectiveState) {
@@ -313,18 +364,37 @@ export const Upload = forwardRef(function Upload({ dataTestId, variant = 'rectan
313
364
  effectiveState === 'success' ||
314
365
  effectiveState === 'error' ||
315
366
  (effectiveState === 'disabled' && hasFiles);
316
- const frameSize = isSquareAny
367
+ // All rectangle states share the same unified 198×69px frame (Figma spec, 2026-05-06).
368
+ const frameSize = (isSquare || isSquareLg)
317
369
  ? ''
318
370
  : isFillContainer
319
371
  ? (isFilled ? 'h-full w-full justify-center' : 'h-full w-full')
320
372
  : 'h-[69px] w-[198px]';
321
- 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' && (isSquareLg ? 'upload-dash-fluid' :
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' :
322
379
  isSquare ? 'upload-dash-4-2-square' :
323
380
  isFillContainer ? 'upload-dash-fluid' : 'upload-dash-4-2'), effectiveState === 'forbidden' && (isSquareLg ? 'upload-dash-fluid-red' :
324
- isSquare ? 'h-[100px] gap-[4px] upload-dash-4-4' :
325
- isFillContainer ? 'upload-dash-fluid-red' : 'upload-dash-4-2-red'), (effectiveState === 'uploading' || effectiveState === 'uploading-spinner') && isSquare && 'h-[100px] p-[11px]!', (effectiveState === 'uploading' || effectiveState === 'uploading-spinner') && isSquareLg && 'h-[238px]', effectiveState === 'success' && isSquare && 'h-[100px] gap-[4px]', effectiveState === 'error' && isSquare && 'h-[100px] gap-[4px] p-[11px]!',
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',
326
385
  // square-lg error: gray border (Figma spec) + allow retry clicks
327
- effectiveState === 'error' && isSquareLg && 'border-fds-gray-30! dark:border-fds-gray-70! cursor-pointer!', effectiveState === 'disabled' && isSquare && hasFiles && 'h-[100px] gap-[4px] p-[11px]!', effectiveState === 'uploading-spinner' && !isSquareAny && '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] }));
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}`))) })] }));
328
397
  });
329
398
  export { uploadVariants };
399
+ /** Alias for `Upload` enforcing single-file semantics. Use instead of `Upload` for all new code. */
330
400
  export const SingleFileUpload = Upload;
@@ -38,6 +38,8 @@ export interface UploadProps extends Omit<HTMLAttributes<HTMLLabelElement>, 'onC
38
38
  idleCompact: string;
39
39
  idleDragDrop: string;
40
40
  uploading: string;
41
+ uploadingTitle: string;
42
+ uploadingSubtitle: string;
41
43
  forbidden: Partial<Record<ForbiddenReason, {
42
44
  title: string;
43
45
  subtitle: string;
@@ -48,8 +50,8 @@ export interface UploadProps extends Omit<HTMLAttributes<HTMLLabelElement>, 'onC
48
50
  t?: UploadTranslator;
49
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. */
50
52
  onUpload?: (files: File[], setIsUploading: (v: boolean, progress?: number) => void) => void;
51
- /** Fires when the trash button is clicked. Removes ALL files. */
52
- 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;
53
55
  /** Fires when an invalid drag/drop is detected. */
54
56
  onForbidden?: (reason: ForbiddenReason) => void;
55
57
  /** A11y label override for the surface. */
@@ -65,4 +67,5 @@ export interface UploadProps extends Omit<HTMLAttributes<HTMLLabelElement>, 'onC
65
67
  }
66
68
  export declare const Upload: import("react").ForwardRefExoticComponent<UploadProps & import("react").RefAttributes<HTMLLabelElement>>;
67
69
  export { uploadVariants };
70
+ /** Alias for `Upload` enforcing single-file semantics. Use instead of `Upload` for all new code. */
68
71
  export declare const SingleFileUpload: import("react").ForwardRefExoticComponent<UploadProps & import("react").RefAttributes<HTMLLabelElement>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freightos/freightwind",
3
- "version": "2.1.6",
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",