@oslokommune/punkt-react 15.0.3 → 15.1.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.
@@ -0,0 +1,425 @@
1
+ /*
2
+ Prinsipper:
3
+ - Fileupload-komponenten er stateless. All state må håndteres utenfor.
4
+ - Dette er en controlled component, så når filer legges til, fjernes eller oppdateres, blir den nye fil-lista sendt ut via onFilesChanged. Komponenten over må dermed oppdatere value-prop'en.
5
+ - Når brukeren velger en fil for opplasting, gir komponenten fila en unik ID.
6
+ - `value`-typen til denne komponenten er en FileItemList
7
+ - Støtte for to opplastingsstrategier:
8
+ - 'form': Filene legges til i et skjema og lastes opp når skjemaet sendes inn.
9
+ - 'custom': Når en fil legges til, kalles en callback-funksjon for å håndtere opplastingen manuelt. Når skjemaet sendes inn, postes bare fil-IDene. Server-applikasjonen kan dermed knytte de allerede opplastede filene til fil-IDene.
10
+ - Støtte for flere filvalg (multiple) og enkel filvalg.
11
+ - Støtte for tilleggsfunksjoner som å legge til kommentarer og endre filnavn via utvidelser.
12
+ - Støtte for tilpassede renderer-komponenter for visning av filer i køen.
13
+ */
14
+ import classNames from 'classnames'
15
+ import { FC, forwardRef, HTMLAttributes, InputHTMLAttributes, ReactNode, useCallback, useMemo, useState } from 'react'
16
+
17
+ import { PktAlert } from '../alert/Alert'
18
+ import { PktInputWrapper } from '../inputwrapper/InputWrapper'
19
+ import { DropZone } from './DropZone'
20
+ import { addCommentOperation } from './extensions/Comments'
21
+ import { removeFileOperation } from './extensions/Remove'
22
+ import { renameFileOperation } from './extensions/Rename'
23
+ import { useFileAttributes } from './hooks'
24
+ import { ItemRenderers, QueueDisplay } from './QueueDisplay'
25
+ import {
26
+ TFileId,
27
+ FileItem,
28
+ TFileItemList,
29
+ TFileTransfer,
30
+ TItemRenderer,
31
+ PktFileUploadContext,
32
+ TQueueItemExtension,
33
+ TUploadStrategy,
34
+ } from './types'
35
+ import { formatFileSize, parseFileSize } from './utils'
36
+
37
+ // Re-export for external use
38
+ export { formatFileSize, parseFileSize } from './utils'
39
+
40
+ /**
41
+ * Shared props for `PktFileUpload` (both `form` and `custom` strategies).
42
+ *
43
+ * The component can be used as:
44
+ * - **controlled**: pass `value` + `onFilesChanged`
45
+ * - **uncontrolled**: pass `defaultValue` (internal state is not stored; you still receive new lists via callbacks)
46
+ */
47
+ interface IBaseFileUploadProps
48
+ extends Omit<
49
+ InputHTMLAttributes<HTMLInputElement>,
50
+ 'checked' | 'size' | 'type' | 'value' | 'onChange' | 'width' | 'defaultValue'
51
+ > {
52
+ /** Called with the next file list when files are added/removed/updated. */
53
+ onFilesChanged?: (files: TFileItemList) => void
54
+ /** Allow selecting/dropping multiple files. */
55
+ multiple?: boolean
56
+ /** Controlled value (recommended). */
57
+ value?: TFileItemList
58
+ /** Upload mode. Defaults to `"form"`. */
59
+ uploadStrategy?: TUploadStrategy
60
+ /** Field name. Used for native input (`form`) or hidden ID inputs (`custom`). */
61
+ name: string
62
+ /** Enable comment operation (disabled automatically in thumbnail view). */
63
+ addCommentsEnabled?: boolean
64
+ /** Enable rename operation (disabled automatically in thumbnail view). */
65
+ renameFilesEnabled?: boolean
66
+ /** Renderer for queue items (`filename`, `thumbnail`, or custom function). */
67
+ itemRenderer?: keyof typeof ItemRenderers | TItemRenderer
68
+ /** Number of trailing characters to keep when truncating long filenames. */
69
+ truncateTail?: number
70
+ /**
71
+ * Called immediately when a file is added in `uploadStrategy="custom"`.
72
+ * Use it to start uploading the file and update `transfers`.
73
+ */
74
+ onFileUploadRequested?: (fileItem: FileItem) => void
75
+ /** Uncontrolled initial value (use either `value` or `defaultValue`, not both). */
76
+ defaultValue?: TFileItemList
77
+ /** Extra operations appended after built-ins (rename/comment). */
78
+ extraOperations?: Array<TQueueItemExtension>
79
+ /** Stretch to full container width (drop zone + queue). */
80
+ fullWidth?: boolean
81
+ /** Allowed formats for built-in validation (extensions like `pdf`, or MIME patterns like `image/*`). */
82
+ allowedFormats?: string[]
83
+ /** Custom message for invalid format. Use `{formats}` placeholder. */
84
+ formatErrorMessage?: string
85
+ /** Max file size (e.g. `"5MB"` or bytes). */
86
+ maxFileSize?: string | number
87
+ /** Custom message for size validation. Use `{maxSize}` placeholder. */
88
+ sizeErrorMessage?: string
89
+ /** Optional additional validation hook (runs after built-in format/size checks). Return string to block. */
90
+ onFileValidation?: (file: File) => string | null
91
+ /** External/programmatic error message shown under the component. */
92
+ errorMessage?: string
93
+ /** External error flag (combined with internal validation errors). */
94
+ hasError?: boolean
95
+ /** Disable the whole component (no interaction). */
96
+ disabled?: boolean
97
+ /** Optional label/title (wraps the component in `PktInputWrapper`). */
98
+ label?: string
99
+ /** Help text under the label. */
100
+ helptext?: string | ReactNode
101
+ /** Show "Valgfritt" tag in wrapper. */
102
+ optionalTag?: boolean
103
+ /** Show "Må fylles ut" tag in wrapper. */
104
+ requiredTag?: boolean
105
+ /** Enable image preview modal (only applies to thumbnail renderer). */
106
+ enableImagePreview?: boolean
107
+ }
108
+
109
+ /** Props for `uploadStrategy="form"` (native file input submission). */
110
+ interface IFileInputFileUploadProps
111
+ extends IBaseFileUploadProps,
112
+ Omit<HTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'defaultValue'> {
113
+ uploadStrategy?: 'form'
114
+ onFileUploadRequested?: never
115
+ }
116
+
117
+ /**
118
+ * Props for `uploadStrategy="custom"` (upload handled externally).
119
+ *
120
+ * Requirements:
121
+ * - `transfers` must be provided (to render status/progress)
122
+ * - `onFileUploadRequested` is called for each added file
123
+ */
124
+ interface ImmediateFileUploadProps extends IBaseFileUploadProps {
125
+ id: string
126
+ uploadStrategy: 'custom'
127
+ transfers: Array<TFileTransfer>
128
+ onFileUploadRequested: (fileItem: FileItem) => void
129
+ onTransferCancelled?: (fileItemId: string) => void
130
+ }
131
+
132
+ export type IPktFileUpload = IFileInputFileUploadProps | ImmediateFileUploadProps
133
+
134
+ export const PktFileUpload: FC<IPktFileUpload> = forwardRef<HTMLInputElement, IPktFileUpload>(
135
+ (
136
+ {
137
+ value: valueProp,
138
+ defaultValue,
139
+ id = 'fileupload-id',
140
+ multiple = false,
141
+ uploadStrategy = 'form',
142
+ addCommentsEnabled = false,
143
+ renameFilesEnabled = false,
144
+ truncateTail,
145
+ onFilesChanged,
146
+ onFileUploadRequested,
147
+ extraOperations = [],
148
+ itemRenderer: itemRendererProp = ItemRenderers.filename,
149
+ fullWidth = false,
150
+ allowedFormats,
151
+ formatErrorMessage,
152
+ maxFileSize, // default to 5 MB
153
+ sizeErrorMessage,
154
+ onFileValidation,
155
+ errorMessage: externalErrorMessage,
156
+ hasError: externalHasError = false,
157
+ disabled = false,
158
+ label,
159
+ helptext,
160
+ optionalTag,
161
+ requiredTag,
162
+ enableImagePreview = false,
163
+ ...props
164
+ }: IPktFileUpload,
165
+ forwardedRef,
166
+ ) => {
167
+ const transfers = 'transfers' in props ? props.transfers : undefined
168
+ const [validationError, setValidationError] = useState<string | null>(null)
169
+
170
+ // Combine external error with internal validation error
171
+ const hasError = externalHasError || !!validationError
172
+ const errorMessage = externalErrorMessage || validationError
173
+
174
+ // Parse maxFileSize once (supports "5MB" strings or raw bytes)
175
+ const maxFileSizeBytes = maxFileSize ? parseFileSize(maxFileSize) : undefined
176
+
177
+ // Built-in validation function
178
+ const validateFile = useCallback(
179
+ (file: File): string | null => {
180
+ // 1. Check format if allowedFormats is specified
181
+ if (allowedFormats && allowedFormats.length > 0) {
182
+ const fileExtension = file.name.split('.').pop()?.toLowerCase() || ''
183
+ const fileMimeType = file.type.toLowerCase()
184
+
185
+ const isAllowed = allowedFormats.some((format) => {
186
+ const normalizedFormat = format.toLowerCase().replace(/^\./, '') // Remove leading dot if present
187
+
188
+ // Check MIME type patterns (e.g. 'image/*', 'application/pdf')
189
+ if (normalizedFormat.includes('/')) {
190
+ if (normalizedFormat.endsWith('/*')) {
191
+ const category = normalizedFormat.replace('/*', '')
192
+ return fileMimeType.startsWith(category + '/')
193
+ }
194
+ return fileMimeType === normalizedFormat
195
+ }
196
+
197
+ // Check file extension (e.g. 'pdf', 'jpg', '.png')
198
+ return fileExtension === normalizedFormat
199
+ })
200
+
201
+ if (!isAllowed) {
202
+ const formatsDisplay = allowedFormats.join(', ')
203
+ const defaultMessage = `Ugyldig filtype. Tillatte formater: ${formatsDisplay}`
204
+ return formatErrorMessage?.replace('{formats}', formatsDisplay) || defaultMessage
205
+ }
206
+ }
207
+
208
+ // 2. Check file size if maxFileSize is specified
209
+ if (maxFileSizeBytes && file.size > maxFileSizeBytes) {
210
+ const maxSizeDisplay = formatFileSize(maxFileSizeBytes)
211
+ const defaultMessage = `Filen er for stor. Maks størrelse er ${maxSizeDisplay}.`
212
+ return sizeErrorMessage?.replace('{maxSize}', maxSizeDisplay) || defaultMessage
213
+ }
214
+
215
+ // 3. Run custom validation if provided
216
+ if (onFileValidation) {
217
+ return onFileValidation(file)
218
+ }
219
+
220
+ return null
221
+ },
222
+ [allowedFormats, formatErrorMessage, maxFileSizeBytes, sizeErrorMessage, onFileValidation],
223
+ )
224
+
225
+ if (valueProp !== undefined && defaultValue !== undefined) {
226
+ // eslint-disable-next-line no-console
227
+ console.warn(
228
+ "PktFileupload: Både value og defaultValue er angitt. Komponenten kan være enten 'controlled' eller 'uncontrolled', ikke begge deler. Bruk kun én av dem. Se https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable",
229
+ )
230
+ }
231
+ if (valueProp !== undefined && onFilesChanged === undefined) {
232
+ // eslint-disable-next-line no-console
233
+ console.warn(
234
+ "PktFileupload: value-prop er angitt uten onFilesChanged-callback. Når en komponent er 'controlled', må endringer håndteres via en callback. Vennligst legg til onFilesChanged-callback for å håndtere endringer i fil-listen. Se https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable",
235
+ )
236
+ }
237
+
238
+ const isControlled = valueProp !== undefined
239
+ const value = isControlled ? valueProp : defaultValue || []
240
+
241
+ const itemRenderer = typeof itemRendererProp === 'string' ? ItemRenderers[itemRendererProp] : itemRendererProp
242
+ const isThumbnailView = itemRendererProp === 'thumbnail'
243
+
244
+ const effectiveRenameEnabled = renameFilesEnabled && !isThumbnailView
245
+ const effectiveCommentsEnabled = addCommentsEnabled && !isThumbnailView
246
+
247
+ const onFilesAdded = useCallback(
248
+ (addedFileItems: TFileItemList) => {
249
+ // Run validation on each file
250
+ for (const fileItem of addedFileItems) {
251
+ const error = validateFile(fileItem.file)
252
+ if (error) {
253
+ setValidationError(error)
254
+ return // Don't add any files if validation fails
255
+ }
256
+ }
257
+
258
+ // Clear any previous validation error
259
+ setValidationError(null)
260
+
261
+ const newValue: TFileItemList = multiple ? [...value, ...addedFileItems] : [addedFileItems[0]]
262
+
263
+ if (onFilesChanged) {
264
+ onFilesChanged(newValue)
265
+ }
266
+ if (uploadStrategy === 'custom' && onFileUploadRequested) {
267
+ addedFileItems.forEach((fileItem) => {
268
+ onFileUploadRequested?.(fileItem)
269
+ })
270
+ }
271
+ },
272
+ [onFilesChanged, value, multiple, uploadStrategy, onFileUploadRequested, validateFile],
273
+ )
274
+
275
+ const onFileUpdated = useCallback(
276
+ (fileId: TFileId, updates: Partial<FileItem>) => {
277
+ const newValue = value.map((fileItem) => (fileItem.fileId === fileId ? { ...fileItem, ...updates } : fileItem))
278
+ if (onFilesChanged) {
279
+ onFilesChanged(newValue)
280
+ }
281
+ },
282
+ [onFilesChanged, value],
283
+ )
284
+
285
+ const onFileRemoved = useCallback(
286
+ (fileId: TFileId) => {
287
+ const newValue = value.filter((fileItem) => fileItem.fileId !== fileId)
288
+ if (onFilesChanged) {
289
+ onFilesChanged(newValue)
290
+ }
291
+ if ('onTransferCancelled' in props && props.onTransferCancelled) {
292
+ props.onTransferCancelled?.(fileId)
293
+ }
294
+ },
295
+ [onFilesChanged, value, props],
296
+ )
297
+
298
+ const { fileAttributes } = useFileAttributes(value, onFileUpdated)
299
+
300
+ const queueItemExtensions = useMemo(
301
+ () =>
302
+ [
303
+ {
304
+ op: renameFileOperation,
305
+ enabled: effectiveRenameEnabled,
306
+ },
307
+ {
308
+ op: (attributes: Parameters<typeof addCommentOperation>[0]) => addCommentOperation(attributes),
309
+ enabled: effectiveCommentsEnabled,
310
+ },
311
+ ]
312
+ .filter(({ enabled }) => enabled)
313
+ .map(({ op }) => op)
314
+ .concat(...extraOperations),
315
+ [effectiveRenameEnabled, effectiveCommentsEnabled],
316
+ )
317
+
318
+ // Screen reader announcement IDs
319
+ const srAnnouncementIds = useMemo(
320
+ () => ({
321
+ uploaded: `${id}-uploaded`,
322
+ errors: `${id}-errors`,
323
+ }),
324
+ [id],
325
+ )
326
+
327
+ // Compute counts for screen reader announcements.
328
+ // F.eks. "1 av 3 filer lastet opp" eller "2 av 3 filer feilet"
329
+ const { totalCount, uploadedCount, failedCount } = useMemo(() => {
330
+ const totalCount = value.length
331
+ if (!transfers) {
332
+ return { totalCount, uploadedCount: 0, failedCount: 0 }
333
+ }
334
+
335
+ let uploadedCount = 0
336
+ let failedCount = 0
337
+
338
+ for (const fileItem of value) {
339
+ const transfer = transfers.find((t) => t.fileId === fileItem.fileId)
340
+ if (transfer?.progress === 'done') uploadedCount++
341
+ if (transfer?.progress === 'error') failedCount++
342
+ }
343
+
344
+ return { totalCount, uploadedCount, failedCount }
345
+ }, [value, transfers])
346
+
347
+ const totalLabel = totalCount === 1 ? 'fil' : 'filer'
348
+
349
+ const fileUploadContent = (
350
+ <div
351
+ className={classNames('pkt-fileupload', {
352
+ 'pkt-fileupload--full-width': fullWidth,
353
+ 'pkt-fileupload--error': hasError,
354
+ 'pkt-fileupload--disabled': disabled,
355
+ })}
356
+ aria-disabled={disabled}
357
+ {...(disabled ? { inert: '' as any } : {})}
358
+ >
359
+ <PktFileUploadContext.Provider value={{ name: props.name || '', multiple: !!multiple, id }}>
360
+ {/* Screen reader announcements - visually hidden */}
361
+ <div id={srAnnouncementIds.uploaded} className="pkt-sr-only" aria-live="polite" aria-atomic="true">
362
+ {totalCount > 0 && uploadedCount > 0 && `${uploadedCount} av ${totalCount} ${totalLabel} lastet opp`}
363
+ </div>
364
+ <div id={srAnnouncementIds.errors} className="pkt-sr-only" aria-live="assertive" aria-atomic="true">
365
+ {totalCount > 0 &&
366
+ failedCount > 0 &&
367
+ `Feil ved opplasting: ${failedCount} av ${totalCount} ${totalLabel} feilet`}
368
+ </div>
369
+
370
+ <DropZone
371
+ id={id}
372
+ name={props.name || ''}
373
+ value={value}
374
+ onFilesAdded={onFilesAdded}
375
+ multiple={multiple}
376
+ uploadStrategy={uploadStrategy}
377
+ ref={forwardedRef}
378
+ accept={isThumbnailView ? '.jpeg, .jpg, .png, .gif, .webp, .heic' : props.accept}
379
+ isThumbnailView={isThumbnailView}
380
+ disabled={disabled}
381
+ srAnnouncementIds={srAnnouncementIds}
382
+ />
383
+ {hasError && errorMessage && (
384
+ <PktAlert skin="error" aria-live="assertive" role="alert" compact className="pkt-fileupload__error-alert">
385
+ {errorMessage}
386
+ </PktAlert>
387
+ )}
388
+ <QueueDisplay
389
+ files={value}
390
+ cancelTransfer={onFileRemoved}
391
+ truncateTail={truncateTail}
392
+ transfers={transfers}
393
+ ItemRenderer={itemRenderer}
394
+ enableImagePreview={isThumbnailView && enableImagePreview}
395
+ queueItemOperations={queueItemExtensions
396
+ .map((ext) => ext(fileAttributes))
397
+ .concat(removeFileOperation(onFileRemoved))}
398
+ />
399
+ </PktFileUploadContext.Provider>
400
+ </div>
401
+ )
402
+
403
+ // Wrap with InputWrapper if label is provided
404
+ if (label) {
405
+ return (
406
+ <PktInputWrapper
407
+ forId={id}
408
+ label={label}
409
+ helptext={helptext}
410
+ disabled={disabled}
411
+ hasError={hasError}
412
+ optionalTag={optionalTag}
413
+ requiredTag={requiredTag}
414
+ className="pkt-fileupload-wrapper"
415
+ >
416
+ {fileUploadContent}
417
+ </PktInputWrapper>
418
+ )
419
+ }
420
+
421
+ return fileUploadContent
422
+ },
423
+ )
424
+
425
+ PktFileUpload.displayName = 'PktFileUpload'
@@ -0,0 +1,222 @@
1
+ import '@testing-library/jest-dom'
2
+
3
+ import { render, screen } from '@testing-library/react'
4
+ import { vi } from 'vitest'
5
+
6
+ import { FileItem, TFileItemList, TFileTransfer } from './types'
7
+ import { PktFileUpload } from './FileUpload'
8
+
9
+ // Polyfill for ResizeObserver
10
+ if (!global.ResizeObserver) {
11
+ class ResizeObserverPolyfill {
12
+ observe() {}
13
+ unobserve() {}
14
+ disconnect() {}
15
+ }
16
+
17
+ global.ResizeObserver = ResizeObserverPolyfill as any
18
+ }
19
+
20
+ const NOOP = () => {}
21
+
22
+ const createMockFile = (name: string, type = 'text/plain'): File => new File(['content'], name, { type })
23
+
24
+ const createFileItem = (name: string, fileId?: string) => new FileItem(createMockFile(name), fileId)
25
+
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()
35
+ }
36
+
37
+ describe('QueueDisplay', () => {
38
+ describe('Progress states', () => {
39
+ it.each([
40
+ {
41
+ state: 'queued',
42
+ progress: 'queued' as TFileTransfer['progress'],
43
+ filename: 'queued-file.pdf',
44
+ description: 'when no transfer exists',
45
+ },
46
+ {
47
+ state: 'done',
48
+ progress: 'done' as TFileTransfer['progress'],
49
+ filename: 'completed-file.pdf',
50
+ description: 'after successful upload',
51
+ },
52
+ {
53
+ state: 'error',
54
+ progress: 'error' as TFileTransfer['progress'],
55
+ filename: 'failed-file.pdf',
56
+ description: 'after failed upload',
57
+ },
58
+ {
59
+ state: 'canceled',
60
+ progress: 'canceled' as TFileTransfer['progress'],
61
+ filename: 'canceled-file.pdf',
62
+ description: 'when canceled',
63
+ },
64
+ // { state: 'in-progress', progress: 10 as FileTransfer['progress'], filename: 'in-progress-file.pdf', description: 'when canceled' },
65
+ ])('should display file with $state state $description', ({ progress, filename, state }) => {
66
+ const initialValue: TFileItemList = [createFileItem(filename, 'file-1')]
67
+ const transfers = progress !== undefined ? [{ fileId: 'file-1', progress }] : []
68
+
69
+ const { container } = render(
70
+ <PktFileUpload
71
+ id={'pktFileUploadId'}
72
+ value={initialValue}
73
+ name={'pktFileUpload'}
74
+ uploadStrategy="custom"
75
+ onFileUploadRequested={vi.fn()}
76
+ transfers={transfers}
77
+ onFilesChanged={NOOP}
78
+ />,
79
+ )
80
+
81
+ // File should be displayed
82
+ expectVisibleFilename(filename)
83
+
84
+ // Should have proper class when not in progress
85
+ const queueItem = container.querySelector('.pkt-fileupload__queue-display__item')
86
+ expect(queueItem).toHaveClass('pkt-fileupload__queue-display__item--' + state)
87
+ expect(queueItem).not.toHaveClass('pkt-fileupload__queue-display__item--in-progress')
88
+
89
+ // Should not show progress bar or cancel button
90
+ expect(container.querySelector('.pkt-fileupload__queue-display__item__progress')).not.toBeInTheDocument()
91
+ expect(screen.queryByText('Avbryt')).not.toBeInTheDocument()
92
+ })
93
+
94
+ it('should display file with in-progress state and progress bar', () => {
95
+ const initialValue: TFileItemList = [createFileItem('uploading-file.pdf', 'file-1')]
96
+
97
+ const { container } = render(
98
+ <PktFileUpload
99
+ id={'pktFileUploadId'}
100
+ value={initialValue}
101
+ name={'pktFileUpload'}
102
+ uploadStrategy="custom"
103
+ onFileUploadRequested={vi.fn()}
104
+ transfers={[{ fileId: 'file-1', progress: 0.45 }]}
105
+ onFilesChanged={NOOP}
106
+ />,
107
+ )
108
+
109
+ // File should be displayed
110
+ expectVisibleFilename('uploading-file.pdf')
111
+
112
+ // Should have the 'in-progress' class
113
+ const queueItem = container.querySelector('.pkt-fileupload__queue-display__item')
114
+ expect(queueItem).toHaveClass('pkt-fileupload__queue-display__item--in-progress')
115
+ expect(queueItem).not.toHaveClass('pkt-fileupload__queue-display__item--done')
116
+
117
+ // Should show progress bar
118
+ expect(container.querySelector('.pkt-fileupload__queue-display__item__progress')).toBeInTheDocument()
119
+
120
+ // Should show percentage
121
+ expect(screen.getByText('45%')).toBeInTheDocument()
122
+
123
+ // Should show cancel button
124
+ expect(screen.getByText('Avbryt')).toBeInTheDocument()
125
+ })
126
+
127
+ it('should display multiple files with different progress states', () => {
128
+ const initialValue: TFileItemList = [
129
+ createFileItem('queued-file.pdf', 'file-1'),
130
+ createFileItem('uploading-file.pdf', 'file-2'),
131
+ createFileItem('completed-file.pdf', 'file-3'),
132
+ createFileItem('failed-file.pdf', 'file-4'),
133
+ ]
134
+
135
+ const { container } = render(
136
+ <PktFileUpload
137
+ id={'pktFileUploadId'}
138
+ value={initialValue}
139
+ name={'pktFileUpload'}
140
+ uploadStrategy="custom"
141
+ onFileUploadRequested={vi.fn()}
142
+ transfers={[
143
+ { fileId: 'file-2', progress: 0.75 },
144
+ { fileId: 'file-3', progress: 'done' },
145
+ { fileId: 'file-4', progress: 'error' },
146
+ ]}
147
+ onFilesChanged={NOOP}
148
+ />,
149
+ )
150
+
151
+ // All files should be displayed
152
+ expectVisibleFilename('queued-file.pdf')
153
+ expectVisibleFilename('uploading-file.pdf')
154
+ expectVisibleFilename('completed-file.pdf')
155
+ expectVisibleFilename('failed-file.pdf')
156
+
157
+ // Should have 4 queue items
158
+ const queueItems = container.querySelectorAll('.pkt-fileupload__queue-display__item')
159
+ expect(queueItems).toHaveLength(4)
160
+
161
+ // Only the uploading file should have in-progress class
162
+ const inProgressItems = container.querySelectorAll('.pkt-fileupload__queue-display__item--in-progress')
163
+ expect(inProgressItems).toHaveLength(1)
164
+
165
+ // Only the uploading file should show progress bar and percentage
166
+ expect(screen.getByText('75%')).toBeInTheDocument()
167
+ expect(container.querySelectorAll('.pkt-fileupload__queue-display__item__progress')).toHaveLength(1)
168
+ })
169
+
170
+ it('should update progress percentage when transfer progresses', () => {
171
+ const initialValue: TFileItemList = [createFileItem('uploading-file.pdf', 'file-1')]
172
+
173
+ const { container, rerender } = render(
174
+ <PktFileUpload
175
+ id={'pktFileUploadId'}
176
+ value={initialValue}
177
+ name={'pktFileUpload'}
178
+ uploadStrategy="custom"
179
+ onFileUploadRequested={vi.fn()}
180
+ transfers={[{ fileId: 'file-1', progress: 0.25 }]}
181
+ onFilesChanged={NOOP}
182
+ />,
183
+ )
184
+
185
+ // Initially at 25%
186
+ expect(screen.getByText('25%')).toBeInTheDocument()
187
+
188
+ // Update to 50%
189
+ rerender(
190
+ <PktFileUpload
191
+ id={'pktFileUploadId'}
192
+ value={initialValue}
193
+ name={'pktFileUpload'}
194
+ uploadStrategy="custom"
195
+ onFileUploadRequested={vi.fn()}
196
+ transfers={[{ fileId: 'file-1', progress: 0.5 }]}
197
+ onFilesChanged={NOOP}
198
+ />,
199
+ )
200
+
201
+ expect(screen.queryByText('25%')).not.toBeInTheDocument()
202
+ expect(screen.getByText('50%')).toBeInTheDocument()
203
+
204
+ // Update to done
205
+ rerender(
206
+ <PktFileUpload
207
+ id={'pktFileUploadId'}
208
+ value={initialValue}
209
+ name={'pktFileUpload'}
210
+ uploadStrategy="custom"
211
+ onFileUploadRequested={vi.fn()}
212
+ transfers={[{ fileId: 'file-1', progress: 'done' }]}
213
+ onFilesChanged={NOOP}
214
+ />,
215
+ )
216
+
217
+ // Progress percentage should no longer be visible
218
+ expect(screen.queryByText('50%')).not.toBeInTheDocument()
219
+ expect(container.querySelector('.pkt-fileupload__queue-display__item__progress')).not.toBeInTheDocument()
220
+ })
221
+ })
222
+ })