@oslokommune/punkt-react 16.13.3 → 16.15.0

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/{dialog-polyfill.esm-1hPr0gl9-EorOF9Wq.js → dialog-polyfill.esm-BmVHGRTX-CuhXqEqJ.js} +1 -1
  3. package/dist/index.d.ts +50 -47
  4. package/dist/punkt-react.es.js +4560 -3038
  5. package/dist/punkt-react.umd.js +670 -274
  6. package/dist/shared-types/fileupload.d.ts +73 -0
  7. package/dist/shared-types/index.d.ts +2 -0
  8. package/dist/shared-utils/fileupload/announcements.d.ts +11 -0
  9. package/dist/shared-utils/fileupload/filename.d.ts +13 -0
  10. package/dist/shared-utils/fileupload/focus-trap.d.ts +12 -0
  11. package/dist/shared-utils/fileupload/formats.d.ts +30 -0
  12. package/dist/shared-utils/fileupload/index.d.ts +15 -0
  13. package/dist/shared-utils/fileupload/navigation.d.ts +5 -0
  14. package/dist/shared-utils/fileupload/size.d.ts +12 -0
  15. package/dist/shared-utils/fileupload/transfers.d.ts +17 -0
  16. package/dist/shared-utils/fileupload/truncate.d.ts +13 -0
  17. package/dist/shared-utils/fileupload/validation.d.ts +24 -0
  18. package/package.json +4 -4
  19. package/src/components/fileupload/DropZone.tsx +35 -22
  20. package/src/components/fileupload/FileUpload.test.tsx +165 -10
  21. package/src/components/fileupload/FileUpload.tsx +116 -184
  22. package/src/components/fileupload/QueueDisplay.test.tsx +51 -8
  23. package/src/components/fileupload/QueueDisplay.tsx +71 -99
  24. package/src/components/fileupload/QueueItemContent.tsx +120 -135
  25. package/src/components/fileupload/Subcomponents.tsx +175 -144
  26. package/src/components/fileupload/Truncate.test.tsx +65 -207
  27. package/src/components/fileupload/Truncate.tsx +24 -120
  28. package/src/components/fileupload/extensions/Comments.tsx +94 -106
  29. package/src/components/fileupload/extensions/Remove.tsx +3 -5
  30. package/src/components/fileupload/extensions/Rename.tsx +45 -42
  31. package/src/components/fileupload/hooks/index.ts +1 -0
  32. package/src/components/fileupload/hooks/useFileAttributes.ts +37 -29
  33. package/src/components/fileupload/hooks/useImagePreview.ts +3 -7
  34. package/src/components/fileupload/hooks/useOperationState.ts +29 -8
  35. package/src/components/fileupload/hooks/useRequiredFormValidation.ts +43 -0
  36. package/src/components/fileupload/hooks.ts +1 -0
  37. package/src/components/fileupload/types.ts +49 -32
  38. package/src/components/fileupload/utils.ts +3 -54
  39. package/src/components/inputwrapper/InputWrapper.tsx +4 -1
  40. package/src/components/modal/Modal.tsx +27 -15
  41. package/src/components/searchinput/SearchInput.tsx +14 -13
  42. package/src/components/types.ts +1 -1
  43. package/src/components/fileupload/texts.ts +0 -20
@@ -27,35 +27,39 @@ import {
27
27
  useState,
28
28
  } from 'react'
29
29
 
30
+ import { defaultFileUploadStrings } from 'shared-types'
31
+ import {
32
+ countFileTransferStates,
33
+ resolveAcceptAttribute,
34
+ THUMBNAIL_ACCEPT,
35
+ validateFile as sharedValidateFile,
36
+ } from 'shared-utils/fileupload'
37
+
30
38
  import { PktAlert } from '../alert/Alert'
31
39
  import { PktInputWrapper } from '../inputwrapper/InputWrapper'
32
40
  import { DropZone } from './DropZone'
33
41
  import { addCommentOperation } from './extensions/Comments'
34
42
  import { removeFileOperation } from './extensions/Remove'
35
43
  import { renameFileOperation } from './extensions/Rename'
36
- import { useFileAttributes } from './hooks'
44
+ import { useFileAttributes, useRequiredFormValidation } from './hooks'
37
45
  import { ItemRenderers, QueueDisplay } from './QueueDisplay'
38
46
  import {
39
47
  TFileId,
40
48
  FileItem,
41
49
  TFileItemList,
42
50
  TFileTransfer,
51
+ TFileValidateDetail,
43
52
  TItemRenderer,
44
53
  PktFileUploadContext,
45
- TQueueItemExtension,
54
+ TQueueItemOperation,
46
55
  TUploadStrategy,
47
56
  } from './types'
48
- import { formatFileSize, parseFileSize } from './utils'
49
57
 
50
58
  // Re-export for external use
51
- export { formatFileSize, parseFileSize } from './utils'
59
+ export { formatFileSize, parseFileSize } from 'shared-utils/fileupload'
52
60
 
53
61
  /**
54
62
  * Shared props for `PktFileUpload` (both `form` and `custom` strategies).
55
- *
56
- * The component can be used as:
57
- * - **controlled**: pass `value` + `onFilesChanged`
58
- * - **uncontrolled**: pass `defaultValue` (internal state is not stored; you still receive new lists via callbacks)
59
63
  */
60
64
  interface IBaseFileUploadProps
61
65
  extends Omit<
@@ -88,9 +92,9 @@ interface IBaseFileUploadProps
88
92
  /** Uncontrolled initial value (use either `value` or `defaultValue`, not both). */
89
93
  defaultValue?: TFileItemList
90
94
  /** Extra operations appended after built-ins (rename/comment). */
91
- extraOperations?: Array<TQueueItemExtension>
95
+ extraOperations?: Array<TQueueItemOperation>
92
96
  /** Stretch to full container width (drop zone + queue). */
93
- fullWidth?: boolean
97
+ fullwidth?: boolean
94
98
  /** Allowed formats for built-in validation (extensions like `pdf`, or MIME patterns like `image/*`). */
95
99
  allowedFormats?: string[]
96
100
  /** Custom message for invalid format. Use `{formats}` placeholder. */
@@ -101,6 +105,11 @@ interface IBaseFileUploadProps
101
105
  sizeErrorMessage?: string
102
106
  /** Optional additional validation hook (runs after built-in format/size checks). Return string to block. */
103
107
  onFileValidation?: (file: File) => string | null
108
+ /**
109
+ * Low-level validation escape hatch — Lit parity with the `file-validate` CustomEvent.
110
+ * Receives a detail object whose `errorMessage` can be mutated; set it to reject the file.
111
+ */
112
+ onFileValidate?: (detail: TFileValidateDetail) => void
104
113
  /** External/programmatic error message shown under the component. */
105
114
  errorMessage?: string
106
115
  /** External error flag (combined with internal validation errors). */
@@ -121,21 +130,13 @@ interface IBaseFileUploadProps
121
130
  enableImagePreview?: boolean
122
131
  }
123
132
 
124
- /** Props for `uploadStrategy="form"` (native file input submission). */
125
133
  interface IFileInputFileUploadProps
126
134
  extends IBaseFileUploadProps,
127
- Omit<HTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'defaultValue'> {
135
+ Omit<HTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'defaultValue'> {
128
136
  uploadStrategy?: 'form'
129
137
  onFileUploadRequested?: never
130
138
  }
131
139
 
132
- /**
133
- * Props for `uploadStrategy="custom"` (upload handled externally).
134
- *
135
- * Requirements:
136
- * - `transfers` must be provided (to render status/progress)
137
- * - `onFileUploadRequested` is called for each added file
138
- */
139
140
  interface ImmediateFileUploadProps extends IBaseFileUploadProps {
140
141
  id: string
141
142
  uploadStrategy: 'custom'
@@ -156,17 +157,18 @@ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IP
156
157
  uploadStrategy = 'form',
157
158
  addCommentsEnabled = false,
158
159
  renameFilesEnabled = false,
159
- truncateTail,
160
+ truncateTail = 4,
160
161
  onFilesChanged,
161
162
  onFileUploadRequested,
162
163
  extraOperations = [],
163
164
  itemRenderer: itemRendererProp = ItemRenderers.filename,
164
- fullWidth = false,
165
+ fullwidth = false,
165
166
  allowedFormats,
166
167
  formatErrorMessage,
167
- maxFileSize, // default to 5 MB
168
+ maxFileSize,
168
169
  sizeErrorMessage,
169
170
  onFileValidation,
171
+ onFileValidate,
170
172
  errorMessage: externalErrorMessage,
171
173
  hasError: externalHasError = false,
172
174
  disabled = false,
@@ -189,64 +191,41 @@ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IP
189
191
 
190
192
  const transfers = 'transfers' in props ? props.transfers : undefined
191
193
  const [validationError, setValidationError] = useState<string | null>(null)
194
+ const [addedAnnouncement, setAddedAnnouncement] = useState('')
195
+
196
+ useEffect(() => {
197
+ if (!addedAnnouncement) return
198
+ const t = setTimeout(() => setAddedAnnouncement(''), 1500)
199
+ return () => clearTimeout(t)
200
+ }, [addedAnnouncement])
192
201
 
193
- // Combine external error with internal validation error
194
- const hasError = externalHasError || !!validationError
195
- const errorMessage = externalErrorMessage || validationError
202
+ const errorMessage = validationError ?? externalErrorMessage ?? null
203
+ const hasError = externalHasError || !!validationError || !!(externalErrorMessage && externalErrorMessage.trim())
196
204
  const errorMessageId = errorMessage ? `${id}-error` : undefined
197
205
  const helptextId = helptext ? `${id}-helptext` : undefined
198
206
  const describedBy = [ariaDescribedByProp, helptextId, errorMessageId].filter(Boolean).join(' ') || undefined
199
207
  const isAriaInvalid = hasError || ariaInvalidProp === true || ariaInvalidProp === 'true'
200
208
 
201
- // Parse maxFileSize once (supports "5MB" strings or raw bytes)
202
- const maxFileSizeBytes = maxFileSize ? parseFileSize(maxFileSize) : undefined
203
-
204
- // Built-in validation function
205
209
  const validateFile = useCallback(
206
210
  (file: File): string | null => {
207
- // 1. Check format if allowedFormats is specified
208
- if (allowedFormats && allowedFormats.length > 0) {
209
- const fileExtension = file.name.split('.').pop()?.toLowerCase() || ''
210
- const fileMimeType = file.type.toLowerCase()
211
-
212
- const isAllowed = allowedFormats.some((format) => {
213
- const normalizedFormat = format.toLowerCase().replace(/^\./, '') // Remove leading dot if present
214
-
215
- // Check MIME type patterns (e.g. 'image/*', 'application/pdf')
216
- if (normalizedFormat.includes('/')) {
217
- if (normalizedFormat.endsWith('/*')) {
218
- const category = normalizedFormat.replace('/*', '')
219
- return fileMimeType.startsWith(category + '/')
220
- }
221
- return fileMimeType === normalizedFormat
222
- }
223
-
224
- // Check file extension (e.g. 'pdf', 'jpg', '.png')
225
- return fileExtension === normalizedFormat
226
- })
227
-
228
- if (!isAllowed) {
229
- const formatsDisplay = allowedFormats.join(', ')
230
- const defaultMessage = `Ugyldig filtype. Tillatte formater: ${formatsDisplay}`
231
- return formatErrorMessage?.replace('{formats}', formatsDisplay) || defaultMessage
232
- }
233
- }
234
-
235
- // 2. Check file size if maxFileSize is specified
236
- if (maxFileSizeBytes && file.size > maxFileSizeBytes) {
237
- const maxSizeDisplay = formatFileSize(maxFileSizeBytes)
238
- const defaultMessage = `Filen er for stor. Maks størrelse er ${maxSizeDisplay}.`
239
- return sizeErrorMessage?.replace('{maxSize}', maxSizeDisplay) || defaultMessage
240
- }
211
+ const baseError = sharedValidateFile(file, {
212
+ allowedFormats,
213
+ maxFileSize,
214
+ formatErrorMessage,
215
+ sizeErrorMessage,
216
+ onFileValidation,
217
+ })
218
+ if (baseError) return baseError
241
219
 
242
- // 3. Run custom validation if provided
243
- if (onFileValidation) {
244
- return onFileValidation(file)
220
+ if (onFileValidate) {
221
+ const detail: TFileValidateDetail = { file, errorMessage: null }
222
+ onFileValidate(detail)
223
+ if (detail.errorMessage) return detail.errorMessage
245
224
  }
246
225
 
247
226
  return null
248
227
  },
249
- [allowedFormats, formatErrorMessage, maxFileSizeBytes, sizeErrorMessage, onFileValidation],
228
+ [allowedFormats, formatErrorMessage, maxFileSize, sizeErrorMessage, onFileValidation, onFileValidate],
250
229
  )
251
230
 
252
231
  if (valueProp !== undefined && defaultValue !== undefined) {
@@ -263,168 +242,120 @@ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IP
263
242
  }
264
243
 
265
244
  const isControlled = valueProp !== undefined
266
- const value = isControlled ? valueProp : defaultValue || []
245
+ const [internalValue, setInternalValue] = useState<TFileItemList>(() => defaultValue ?? [])
246
+ const value = isControlled ? (valueProp as TFileItemList) : internalValue
267
247
 
268
- useEffect(() => {
269
- if (uploadStrategy !== 'custom' || !required) return
270
- const input = fileInputRef.current
271
- const form = input?.form
272
- if (!form) return
273
-
274
- const handleFormSubmit = (event: SubmitEvent) => {
275
- if (value.length > 0) return
276
- event.preventDefault()
277
- setValidationError('Velg minst én fil før du sender inn skjemaet.')
278
- }
279
-
280
- form.addEventListener('submit', handleFormSubmit)
281
- return () => {
282
- form.removeEventListener('submit', handleFormSubmit)
283
- }
284
- }, [required, uploadStrategy, value.length])
248
+ useRequiredFormValidation(fileInputRef, {
249
+ uploadStrategy,
250
+ required,
251
+ currentFileCount: value.length,
252
+ onMissingFiles: useCallback(() => setValidationError(defaultFileUploadStrings.requiredMissing), []),
253
+ })
285
254
 
286
255
  const itemRenderer = typeof itemRendererProp === 'string' ? ItemRenderers[itemRendererProp] : itemRendererProp
287
256
  const isThumbnailView = itemRendererProp === 'thumbnail'
288
257
  const explicitAccept = typeof props.accept === 'string' ? props.accept.trim() : undefined
289
- const acceptFromAllowedFormats = useMemo(() => {
290
- if (!allowedFormats || allowedFormats.length === 0) return undefined
291
- return allowedFormats
292
- .map((format) => format.trim())
293
- .filter(Boolean)
294
- .map((format) => {
295
- const normalized = format.toLowerCase()
296
- if (normalized.includes('/')) return normalized
297
- if (normalized.startsWith('.')) return normalized
298
- return `.${normalized}`
299
- })
300
- .join(', ')
301
- }, [allowedFormats])
302
- const resolvedAcceptForDropZone = explicitAccept || acceptFromAllowedFormats
258
+ const resolvedAcceptForDropZone = useMemo(() => {
259
+ const resolved = resolveAcceptAttribute(explicitAccept, allowedFormats)
260
+ return resolved || undefined
261
+ }, [explicitAccept, allowedFormats])
303
262
 
304
263
  const effectiveRenameEnabled = renameFilesEnabled && !isThumbnailView
305
264
  const effectiveCommentsEnabled = addCommentsEnabled && !isThumbnailView
306
265
 
307
266
  const onFilesAdded = useCallback(
308
267
  (addedFileItems: TFileItemList) => {
309
- // Run validation on each file
310
268
  for (const fileItem of addedFileItems) {
311
269
  const error = validateFile(fileItem.file)
312
270
  if (error) {
313
271
  setValidationError(error)
314
- return // Don't add any files if validation fails
272
+ return
315
273
  }
316
274
  }
317
275
 
318
- // Clear any previous validation error
319
276
  setValidationError(null)
320
277
 
321
278
  const newValue: TFileItemList = multiple ? [...value, ...addedFileItems] : [addedFileItems[0]]
322
279
 
323
- if (onFilesChanged) {
324
- onFilesChanged(newValue)
325
- }
280
+ const acceptedItems: TFileItemList = multiple ? addedFileItems : [addedFileItems[0]]
281
+ setAddedAnnouncement(
282
+ acceptedItems.length === 1
283
+ ? defaultFileUploadStrings.srFileAdded(acceptedItems[0].attributes.targetFilename)
284
+ : defaultFileUploadStrings.srFilesAdded(acceptedItems.length),
285
+ )
286
+
287
+ if (!isControlled) setInternalValue(newValue)
288
+ onFilesChanged?.(newValue)
289
+
326
290
  if (uploadStrategy === 'custom' && onFileUploadRequested) {
327
291
  addedFileItems.forEach((fileItem) => {
328
292
  onFileUploadRequested?.(fileItem)
329
293
  })
330
294
  }
331
295
  },
332
- [onFilesChanged, value, multiple, uploadStrategy, onFileUploadRequested, validateFile],
296
+ [onFilesChanged, value, multiple, uploadStrategy, onFileUploadRequested, validateFile, isControlled],
333
297
  )
334
298
 
335
299
  const onFileUpdated = useCallback(
336
300
  (fileId: TFileId, updates: Partial<FileItem>) => {
337
301
  const newValue = value.map((fileItem) => (fileItem.fileId === fileId ? { ...fileItem, ...updates } : fileItem))
338
- if (onFilesChanged) {
339
- onFilesChanged(newValue)
340
- }
302
+ if (!isControlled) setInternalValue(newValue)
303
+ onFilesChanged?.(newValue)
341
304
  },
342
- [onFilesChanged, value],
305
+ [onFilesChanged, value, isControlled],
343
306
  )
344
307
 
345
308
  const onFileRemoved = useCallback(
346
309
  (fileId: TFileId) => {
347
310
  const newValue = value.filter((fileItem) => fileItem.fileId !== fileId)
348
- if (onFilesChanged) {
349
- onFilesChanged(newValue)
350
- }
311
+ if (!isControlled) setInternalValue(newValue)
312
+ onFilesChanged?.(newValue)
351
313
  if ('onTransferCancelled' in props && props.onTransferCancelled) {
352
314
  props.onTransferCancelled?.(fileId)
353
315
  }
354
316
  },
355
- [onFilesChanged, value, props],
356
- )
357
-
358
- const { fileAttributes } = useFileAttributes(value, onFileUpdated)
359
-
360
- const queueItemExtensions = useMemo(
361
- () =>
362
- [
363
- {
364
- op: renameFileOperation,
365
- enabled: effectiveRenameEnabled,
366
- },
367
- {
368
- op: (attributes: Parameters<typeof addCommentOperation>[0]) => addCommentOperation(attributes),
369
- enabled: effectiveCommentsEnabled,
370
- },
371
- ]
372
- .filter(({ enabled }) => enabled)
373
- .map(({ op }) => op)
374
- .concat(...extraOperations),
375
- [effectiveRenameEnabled, effectiveCommentsEnabled],
376
- )
377
-
378
- // Screen reader announcement IDs
379
- const srAnnouncementIds = useMemo(
380
- () => ({
381
- uploaded: `${id}-uploaded`,
382
- errors: `${id}-errors`,
383
- }),
384
- [id],
317
+ [onFilesChanged, value, props, isControlled],
385
318
  )
386
319
 
387
- // Compute counts for screen reader announcements.
388
- // F.eks. "1 av 3 filer lastet opp" eller "2 av 3 filer feilet"
389
- const { totalCount, uploadedCount, failedCount } = useMemo(() => {
390
- const totalCount = value.length
391
- if (!transfers) {
392
- return { totalCount, uploadedCount: 0, failedCount: 0 }
393
- }
394
-
395
- let uploadedCount = 0
396
- let failedCount = 0
320
+ const { buildAttributeAccessors } = useFileAttributes(value, onFileUpdated)
397
321
 
398
- for (const fileItem of value) {
399
- const transfer = transfers.find((t) => t.fileId === fileItem.fileId)
400
- if (transfer?.progress === 'done') uploadedCount++
401
- if (transfer?.progress === 'error') failedCount++
402
- }
322
+ const queueItemOperations = useMemo<TQueueItemOperation[]>(() => {
323
+ const operations: TQueueItemOperation[] = []
324
+ if (effectiveRenameEnabled) operations.push(renameFileOperation)
325
+ if (effectiveCommentsEnabled) operations.push(addCommentOperation)
326
+ operations.push(...extraOperations)
327
+ operations.push(removeFileOperation(onFileRemoved))
328
+ return operations
329
+ }, [effectiveRenameEnabled, effectiveCommentsEnabled, extraOperations, onFileRemoved])
403
330
 
404
- return { totalCount, uploadedCount, failedCount }
405
- }, [value, transfers])
331
+ const { totalCount, uploadedCount, failedCount } = useMemo(
332
+ () => countFileTransferStates(value, transfers),
333
+ [value, transfers],
334
+ )
406
335
 
407
- const totalLabel = totalCount === 1 ? 'fil' : 'filer'
336
+ const totalLabel = defaultFileUploadStrings.fileLabel(totalCount)
408
337
 
409
338
  const fileUploadContent = (
410
339
  <div
411
340
  className={classNames('pkt-fileupload', {
412
- 'pkt-fileupload--full-width': fullWidth,
341
+ 'pkt-fileupload--full-width': fullwidth,
413
342
  'pkt-fileupload--error': hasError,
414
343
  'pkt-fileupload--disabled': disabled,
415
344
  })}
416
345
  aria-disabled={disabled}
417
- {...(disabled ? { inert: '' as any } : {})}
346
+ {...(disabled ? { inert: '' } : {})}
418
347
  >
419
348
  <PktFileUploadContext.Provider value={{ name: props.name || '', multiple: !!multiple, id }}>
420
- {/* Screen reader announcements - visually hidden */}
421
- <div id={srAnnouncementIds.uploaded} className="pkt-sr-only" aria-live="polite" aria-atomic="true">
422
- {totalCount > 0 && uploadedCount > 0 && `${uploadedCount} av ${totalCount} ${totalLabel} lastet opp`}
349
+ <div className="pkt-sr-only" aria-live="polite" aria-atomic="true">
350
+ {addedAnnouncement ||
351
+ (totalCount > 0 && uploadedCount > 0
352
+ ? defaultFileUploadStrings.srFilesUploadedOfTotal(uploadedCount, totalCount, totalLabel)
353
+ : '')}
423
354
  </div>
424
- <div id={srAnnouncementIds.errors} className="pkt-sr-only" aria-live="assertive" aria-atomic="true">
355
+ <div className="pkt-sr-only" aria-live="assertive" aria-atomic="true">
425
356
  {totalCount > 0 &&
426
357
  failedCount > 0 &&
427
- `Feil ved opplasting: ${failedCount} av ${totalCount} ${totalLabel} feilet`}
358
+ defaultFileUploadStrings.srFilesFailedOfTotal(failedCount, totalCount, totalLabel)}
428
359
  </div>
429
360
 
430
361
  <DropZone
@@ -435,10 +366,9 @@ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IP
435
366
  multiple={multiple}
436
367
  uploadStrategy={uploadStrategy}
437
368
  ref={fileInputRef}
438
- accept={isThumbnailView ? '.jpeg, .jpg, .png, .gif, .webp, .heic' : resolvedAcceptForDropZone}
369
+ accept={isThumbnailView ? THUMBNAIL_ACCEPT : resolvedAcceptForDropZone}
439
370
  isThumbnailView={isThumbnailView}
440
371
  disabled={disabled}
441
- srAnnouncementIds={srAnnouncementIds}
442
372
  required={required && uploadStrategy === 'form'}
443
373
  aria-required={required ? 'true' : undefined}
444
374
  aria-invalid={isAriaInvalid ? 'true' : undefined}
@@ -457,23 +387,25 @@ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IP
457
387
  {errorMessage}
458
388
  </PktAlert>
459
389
  )}
460
- <QueueDisplay
461
- files={value}
462
- cancelTransfer={onFileRemoved}
463
- truncateTail={truncateTail}
464
- transfers={transfers}
465
- uploadStrategy={uploadStrategy}
466
- ItemRenderer={itemRenderer}
467
- enableImagePreview={isThumbnailView && enableImagePreview}
468
- queueItemOperations={queueItemExtensions
469
- .map((ext) => ext(fileAttributes))
470
- .concat(removeFileOperation(onFileRemoved))}
471
- />
390
+ {value.length > 0 && (
391
+ <QueueDisplay
392
+ files={value}
393
+ cancelTransfer={onFileRemoved}
394
+ truncateTail={truncateTail}
395
+ transfers={transfers}
396
+ uploadStrategy={uploadStrategy}
397
+ ItemRenderer={itemRenderer}
398
+ enableImagePreview={isThumbnailView && enableImagePreview}
399
+ disabled={disabled}
400
+ inputName={props.name || ''}
401
+ buildAttributeAccessors={buildAttributeAccessors}
402
+ queueItemOperations={queueItemOperations}
403
+ />
404
+ )}
472
405
  </PktFileUploadContext.Provider>
473
406
  </div>
474
407
  )
475
408
 
476
- // Wrap with InputWrapper if label is provided
477
409
  if (label) {
478
410
  return (
479
411
  <PktInputWrapper
@@ -484,7 +416,7 @@ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IP
484
416
  hasError={hasError}
485
417
  optionalTag={optionalTag}
486
418
  requiredTag={requiredTag}
487
- className={classNames('pkt-fileupload-wrapper', { 'pkt-fileupload-wrapper--full-width': fullWidth })}
419
+ className={classNames('pkt-fileupload-wrapper', { 'pkt-fileupload-wrapper--full-width': fullwidth })}
488
420
  >
489
421
  {fileUploadContent}
490
422
  </PktInputWrapper>
@@ -24,14 +24,16 @@ const createMockFile = (name: string, type = 'text/plain'): File => new File(['c
24
24
  const createFileItem = (name: string, fileId?: string) => new FileItem(createMockFile(name), fileId)
25
25
 
26
26
  const expectVisibleFilename = (filename: string) => {
27
- // Truncate renders a hidden measuring element with the same text.
28
- // We assert against the visible "first" part to avoid duplicate matches.
29
- expect(
30
- screen.getByText(
31
- (content, element) =>
32
- content === filename && element?.getAttribute('data-pkt-truncate-part') === 'first',
33
- ),
34
- ).toBeInTheDocument()
27
+ // Truncate may render the filename as a single span (data-pkt-truncate-part="first")
28
+ // or as a head + tail pair when the name is long enough to split. Assemble the parts
29
+ // from each title element and assert the filename is found in at least one.
30
+ const titleElements = document.querySelectorAll('.pkt-fileupload__queue-display__item__title')
31
+ const reconstructed = Array.from(titleElements).map((title) => {
32
+ const head = title.querySelector('[data-pkt-truncate-part="first"]')?.textContent ?? ''
33
+ const tail = title.querySelector('[data-pkt-truncate-part="tail"]')?.textContent ?? ''
34
+ return head + tail
35
+ })
36
+ expect(reconstructed).toContain(filename)
35
37
  }
36
38
 
37
39
  describe('QueueDisplay', () => {
@@ -219,4 +221,45 @@ describe('QueueDisplay', () => {
219
221
  expect(container.querySelector('.pkt-fileupload__queue-display__item__progress')).not.toBeInTheDocument()
220
222
  })
221
223
  })
224
+
225
+ describe('Sort order', () => {
226
+ it('renders queue items in the order: in-progress → error → done/queued', () => {
227
+ const initialValue: TFileItemList = [
228
+ createFileItem('done.pdf', 'done-1'),
229
+ createFileItem('failed.pdf', 'error-1'),
230
+ createFileItem('uploading.pdf', 'progress-1'),
231
+ createFileItem('queued.pdf', 'queued-1'),
232
+ ]
233
+ const transfers: TFileTransfer[] = [
234
+ { fileId: 'done-1', progress: 'done' },
235
+ { fileId: 'error-1', progress: 'error', errorMessage: 'failed' },
236
+ { fileId: 'progress-1', progress: 0.5, showProgress: true },
237
+ { fileId: 'queued-1', progress: 'queued' },
238
+ ]
239
+
240
+ const { container } = render(
241
+ <PktFileUpload
242
+ id="sort-order-test"
243
+ value={initialValue}
244
+ name="pktFileUpload"
245
+ uploadStrategy="custom"
246
+ onFileUploadRequested={vi.fn()}
247
+ transfers={transfers}
248
+ onFilesChanged={NOOP}
249
+ />,
250
+ )
251
+
252
+ // Reconstruct each filename from head + tail spans (middle-truncation may split it).
253
+ const queueItems = Array.from(
254
+ container.querySelectorAll<HTMLElement>('.pkt-fileupload__queue-display__item'),
255
+ )
256
+ const visibleNames = queueItems.map((item) => {
257
+ const head = item.querySelector<HTMLElement>('[data-pkt-truncate-part="first"]')?.textContent ?? ''
258
+ const tail = item.querySelector<HTMLElement>('[data-pkt-truncate-part="tail"]')?.textContent ?? ''
259
+ return (head + tail).trim()
260
+ })
261
+
262
+ expect(visibleNames).toEqual(['uploading.pdf', 'failed.pdf', 'done.pdf', 'queued.pdf'])
263
+ })
264
+ })
222
265
  })