@oslokommune/punkt-elements 16.13.2 → 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 (157) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/dist/calendar-Ch1dgnBo.js +656 -0
  3. package/dist/calendar-DkjXla0I.cjs +90 -0
  4. package/dist/{card-CQYxrtL0.cjs → card-C3POnP3O.cjs} +1 -1
  5. package/dist/{card-Bir3-pl2.js → card-CEDbo1t7.js} +2 -2
  6. package/dist/{checkbox-Cspxcvt_.js → checkbox-Bjp43NxA.js} +1 -1
  7. package/dist/{checkbox-BARDb9iV.cjs → checkbox-nTbihMxW.cjs} +1 -1
  8. package/dist/{combobox-CGbFTWq_.cjs → combobox-C4Q6kQEs.cjs} +1 -1
  9. package/dist/{combobox-BcdLZRDU.js → combobox-CL9BLuxt.js} +6 -6
  10. package/dist/components/fileupload/fileupload-base.d.ts +116 -0
  11. package/dist/components/fileupload/fileupload-operations.d.ts +4 -0
  12. package/dist/components/fileupload/fileupload-renderers.d.ts +28 -0
  13. package/dist/components/fileupload/fileupload-types.d.ts +70 -0
  14. package/dist/components/fileupload/fileupload.d.ts +25 -0
  15. package/dist/components/fileupload/fileupload.test.d.ts +0 -0
  16. package/dist/components/fileupload/index.d.ts +4 -0
  17. package/dist/components/index.d.ts +2 -0
  18. package/dist/components/input-wrapper/input-wrapper.d.ts +2 -0
  19. package/dist/controllers/focus-modality-controller.d.ts +31 -0
  20. package/dist/converters-Bffq_YDm.cjs +1 -0
  21. package/dist/converters-jauYkvNh.js +1378 -0
  22. package/dist/{datepicker-OVd8pmnZ.js → datepicker-BUK1Qanw.js} +22 -21
  23. package/dist/{datepicker-DpmlsnH_.cjs → datepicker-DYd3JCs_.cjs} +10 -10
  24. package/dist/docs/components/fileupload.d.ts +27 -0
  25. package/dist/fileupload-BRqioKSY.cjs +396 -0
  26. package/dist/fileupload-Bnl303X0.js +1372 -0
  27. package/dist/fileupload.d.ts +1 -0
  28. package/dist/{header-DWgPgGfS.cjs → header-Dps8o-ZY.cjs} +1 -1
  29. package/dist/{header-a6ztVNp3.js → header-T5O6sfCN.js} +3 -3
  30. package/dist/{helptext-CCBKKLus.js → helptext-C5rG4VkA.js} +2 -2
  31. package/dist/{helptext-D-3QfZv5.cjs → helptext-DQgStO4D.cjs} +1 -1
  32. package/dist/{input-element-Bw1s6NlF.js → input-element-CjebcQd1.js} +3 -3
  33. package/dist/{input-element-B1yxcfz4.cjs → input-element-FGcr5hGd.cjs} +1 -1
  34. package/dist/input-wrapper-B_XFi211.cjs +55 -0
  35. package/dist/{input-wrapper-YfTmpSSY.js → input-wrapper-DBpMVSxt.js} +41 -15
  36. package/dist/{listbox-DuBgTFvH.js → listbox-0NHJvNjz.js} +2 -2
  37. package/dist/{listbox-DCA2JoPV.cjs → listbox-CcThiNBY.cjs} +1 -1
  38. package/dist/{modal-CBEWrAi2.cjs → modal-B6591sWa.cjs} +1 -1
  39. package/dist/{modal-pPp8nia6.js → modal-DyIC3uhs.js} +1 -1
  40. package/dist/pkt-calendar.cjs +1 -1
  41. package/dist/pkt-calendar.js +1 -1
  42. package/dist/pkt-card.cjs +1 -1
  43. package/dist/pkt-card.js +1 -1
  44. package/dist/pkt-checkbox.cjs +1 -1
  45. package/dist/pkt-checkbox.js +1 -1
  46. package/dist/pkt-combobox.cjs +1 -1
  47. package/dist/pkt-combobox.js +1 -1
  48. package/dist/pkt-consent.cjs +1 -1
  49. package/dist/pkt-consent.js +1 -1
  50. package/dist/pkt-datepicker.cjs +1 -1
  51. package/dist/pkt-datepicker.js +1 -1
  52. package/dist/pkt-fileupload.cjs +1 -0
  53. package/dist/pkt-fileupload.js +2 -0
  54. package/dist/pkt-header.cjs +1 -1
  55. package/dist/pkt-header.js +1 -1
  56. package/dist/pkt-heading.cjs +1 -1
  57. package/dist/pkt-heading.js +1 -1
  58. package/dist/pkt-helptext.cjs +1 -1
  59. package/dist/pkt-helptext.js +1 -1
  60. package/dist/pkt-index.cjs +1 -1
  61. package/dist/pkt-index.js +25 -24
  62. package/dist/pkt-input-wrapper.cjs +1 -1
  63. package/dist/pkt-input-wrapper.js +1 -1
  64. package/dist/pkt-link.cjs +1 -1
  65. package/dist/pkt-link.js +1 -1
  66. package/dist/pkt-linkcard.cjs +1 -1
  67. package/dist/pkt-linkcard.js +1 -1
  68. package/dist/pkt-listbox.cjs +1 -1
  69. package/dist/pkt-listbox.js +1 -1
  70. package/dist/pkt-loader.cjs +1 -1
  71. package/dist/pkt-loader.js +1 -1
  72. package/dist/pkt-messagebox.cjs +1 -1
  73. package/dist/pkt-messagebox.js +1 -1
  74. package/dist/pkt-modal.cjs +1 -1
  75. package/dist/pkt-modal.js +1 -1
  76. package/dist/pkt-progressbar.cjs +1 -1
  77. package/dist/pkt-progressbar.js +1 -1
  78. package/dist/pkt-radiobutton.cjs +1 -1
  79. package/dist/pkt-radiobutton.js +1 -1
  80. package/dist/pkt-select.cjs +1 -1
  81. package/dist/pkt-select.js +1 -1
  82. package/dist/pkt-tabs.cjs +1 -1
  83. package/dist/pkt-tabs.js +1 -1
  84. package/dist/pkt-tag.cjs +1 -1
  85. package/dist/pkt-tag.js +1 -1
  86. package/dist/pkt-textarea.cjs +1 -1
  87. package/dist/pkt-textarea.js +1 -1
  88. package/dist/pkt-textinput.cjs +1 -1
  89. package/dist/pkt-textinput.js +1 -1
  90. package/dist/pkt-timepicker.cjs +1 -1
  91. package/dist/pkt-timepicker.js +1 -1
  92. package/dist/{progressbar-CdXtmOBb.cjs → progressbar-CrwpnIkv.cjs} +1 -1
  93. package/dist/{progressbar-Dy9AiYQz.js → progressbar-uC-0W6o4.js} +1 -1
  94. package/dist/{radiobutton-V_KPbup_.js → radiobutton-B-8-wWFf.js} +1 -1
  95. package/dist/{radiobutton-GOnpUbCW.cjs → radiobutton-DvXr7tDk.cjs} +1 -1
  96. package/dist/{select-C2i8sOk5.cjs → select-B75wzvyz.cjs} +1 -1
  97. package/dist/{select-BSAFd8Wa.js → select-D9B8Ighz.js} +3 -3
  98. package/dist/shared-types/fileupload.d.ts +73 -0
  99. package/dist/shared-types/index.d.ts +2 -0
  100. package/dist/shared-utils/fileupload/announcements.d.ts +11 -0
  101. package/dist/shared-utils/fileupload/filename.d.ts +13 -0
  102. package/dist/shared-utils/fileupload/focus-trap.d.ts +12 -0
  103. package/dist/shared-utils/fileupload/formats.d.ts +30 -0
  104. package/dist/shared-utils/fileupload/index.d.ts +15 -0
  105. package/dist/shared-utils/fileupload/navigation.d.ts +5 -0
  106. package/dist/shared-utils/fileupload/size.d.ts +12 -0
  107. package/dist/shared-utils/fileupload/transfers.d.ts +17 -0
  108. package/dist/shared-utils/fileupload/truncate.d.ts +13 -0
  109. package/dist/shared-utils/fileupload/validation.d.ts +24 -0
  110. package/dist/tests/component-registry.d.ts +2 -0
  111. package/dist/{textarea-DYOBhJrN.js → textarea-CIWXMaAD.js} +2 -2
  112. package/dist/{textarea-D7aGOyDf.cjs → textarea-CjqprL6Y.cjs} +1 -1
  113. package/dist/{textinput-DFh8_9cx.cjs → textinput-B0VWmVIF.cjs} +1 -1
  114. package/dist/{textinput-BUgntXnM.js → textinput-CxzAxM8E.js} +2 -2
  115. package/dist/{timepicker-BIfE7KKJ.cjs → timepicker-Cv9juQeB.cjs} +1 -1
  116. package/dist/{timepicker-DaSwn1FA.js → timepicker-DXrpg5C7.js} +2 -2
  117. package/package.json +2 -2
  118. package/src/components/fileupload/fileupload-base.ts +557 -0
  119. package/src/components/fileupload/fileupload-operations.ts +213 -0
  120. package/src/components/fileupload/fileupload-renderers.ts +769 -0
  121. package/src/components/fileupload/fileupload-types.ts +88 -0
  122. package/src/components/fileupload/fileupload.test.ts +949 -0
  123. package/src/components/fileupload/fileupload.ts +469 -0
  124. package/src/components/fileupload/index.ts +34 -0
  125. package/src/components/index.ts +16 -0
  126. package/src/components/input-wrapper/input-wrapper.ts +10 -3
  127. package/dist/calendar-BzEtOJla.js +0 -2031
  128. package/dist/calendar-E5jJLt8D.cjs +0 -90
  129. package/dist/input-wrapper-BRuVELpK.cjs +0 -54
  130. /package/dist/{booleanish-jJLlnr6-.cjs → booleanish-B5EFqFpJ.cjs} +0 -0
  131. /package/dist/{booleanish-BmcOU-43.js → booleanish-CRaKZ75t.js} +0 -0
  132. /package/dist/{consent-DeB_5sWH.js → consent-f_IGyHss.js} +0 -0
  133. /package/dist/{consent-hwu7pp3S.cjs → consent-zh2npq2K.cjs} +0 -0
  134. /package/dist/{dialog-polyfill.esm-1hPr0gl9.js → dialog-polyfill.esm-BmVHGRTX.js} +0 -0
  135. /package/dist/{dialog-polyfill.esm-D3Oinvk0.cjs → dialog-polyfill.esm-CXDsb3l-.cjs} +0 -0
  136. /package/dist/{heading-DiQLPxdf.js → heading-BxvE80bb.js} +0 -0
  137. /package/dist/{heading-BgSWIRii.cjs → heading-DRJTS27k.cjs} +0 -0
  138. /package/dist/{input-wrapper-Dwd-_sKm.cjs → input-wrapper-CYCCx8MP.cjs} +0 -0
  139. /package/dist/{input-wrapper-CDyHTR1V.js → input-wrapper-kw2y6Jp_.js} +0 -0
  140. /package/dist/{link-CyTCpI0k.cjs → link-B8zYc5WK.cjs} +0 -0
  141. /package/dist/{link-CKAFMz9j.js → link-CjJcwcxR.js} +0 -0
  142. /package/dist/{linkcard-BE4-FewK.cjs → linkcard-C4x-NJMi.cjs} +0 -0
  143. /package/dist/{linkcard-B0KOGaGj.js → linkcard-Ddh5R_L-.js} +0 -0
  144. /package/dist/{loader-CX2rsC01.cjs → loader-CGQXAuyM.cjs} +0 -0
  145. /package/dist/{loader-6kFZUErT.js → loader-DeizpX45.js} +0 -0
  146. /package/dist/{messagebox-Din9X53b.cjs → messagebox-Ccu29rDv.cjs} +0 -0
  147. /package/dist/{messagebox-Cwlg5sOr.js → messagebox-DqoPqo4K.js} +0 -0
  148. /package/dist/{pkt-options-controller-CXUGx134.cjs → pkt-options-controller-BPBb_O2K.cjs} +0 -0
  149. /package/dist/{pkt-options-controller-CRrI6WMk.js → pkt-options-controller-DbLrdRwH.js} +0 -0
  150. /package/dist/{repeat-Cepr3469.js → repeat-DV1O43YU.js} +0 -0
  151. /package/dist/{repeat-CqcDYBxr.cjs → repeat-DaDFvx86.cjs} +0 -0
  152. /package/dist/{tabs-Rc9N9HWa.js → tabs-4eWA7CsK.js} +0 -0
  153. /package/dist/{tabs-BfurI6C3.cjs → tabs-RnDtIFv3.cjs} +0 -0
  154. /package/dist/{tag-DLthw5b4.cjs → tag-DWtajZk6.cjs} +0 -0
  155. /package/dist/{tag-VwykmuQR.js → tag-hl59gziI.js} +0 -0
  156. /package/dist/{utils-CUqCKGvp.js → utils-BOOZ0ppt.js} +0 -0
  157. /package/dist/{utils-CjAbIttF.cjs → utils-CM67Oudc.cjs} +0 -0
@@ -0,0 +1,949 @@
1
+ import '@testing-library/jest-dom'
2
+ import { html } from 'lit'
3
+ import { createElementTest, BaseTestConfig } from '@/tests/test-framework'
4
+ import { CustomElementFor } from '@/tests/component-registry'
5
+ import './fileupload'
6
+ import { IPktFileUpload, FileItem, TFileTransfer, TQueueItemOperation } from './fileupload-types'
7
+
8
+ interface FileUploadTestConfig extends Partial<IPktFileUpload>, BaseTestConfig {}
9
+
10
+ const createFileUploadTest = async (config: FileUploadTestConfig = {}) => {
11
+ // Default empty light-DOM child: stray "Test content" breaks Lit render on `pkt-fileupload` (createRenderRoot = this).
12
+ const { content = '', ...attributeConfig } = config
13
+ const { container, element } = await createElementTest<
14
+ CustomElementFor<'pkt-fileupload'>,
15
+ BaseTestConfig
16
+ >('pkt-fileupload', { content })
17
+
18
+ Object.entries(attributeConfig).forEach(([key, value]) => {
19
+ ;(element as any)[key] = value
20
+ })
21
+ await element.updateComplete
22
+
23
+ return {
24
+ container,
25
+ fileupload: element,
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Drive the component through its public input — same pattern as React's
31
+ * `fireEvent.change(fileInput, { target: { files } })`. Tests should prefer
32
+ * this over poking `.addFiles()` directly so the assertions stay close to
33
+ * the user-visible flow.
34
+ */
35
+ const selectFiles = async (
36
+ fileupload: CustomElementFor<'pkt-fileupload'>,
37
+ files: File[],
38
+ ): Promise<void> => {
39
+ const input = fileupload.querySelector<HTMLInputElement>('input[type="file"]')
40
+ if (!input) throw new Error('Expected pkt-fileupload to render a native file input')
41
+ Object.defineProperty(input, 'files', {
42
+ configurable: true,
43
+ value: files,
44
+ })
45
+ input.dispatchEvent(new Event('change', { bubbles: true }))
46
+ await fileupload.updateComplete
47
+ }
48
+
49
+ /** Convenience to track every `files-changed` event the component emits. */
50
+ const recordFilesChanged = (fileupload: CustomElementFor<'pkt-fileupload'>) => {
51
+ const events: Array<{ files: FileItem[]; reason: string; changedFileIds?: string[] }> = []
52
+ fileupload.addEventListener('files-changed', (event) => {
53
+ events.push((event as CustomEvent).detail)
54
+ })
55
+ return events
56
+ }
57
+
58
+ /** Returns the most recent file list as observed via `files-changed` events. */
59
+ const lastFilesFromEvents = (
60
+ events: Array<{ files: FileItem[] }>,
61
+ ): FileItem[] => (events.length === 0 ? [] : events[events.length - 1].files)
62
+
63
+ afterEach(() => {
64
+ document.body.innerHTML = ''
65
+ })
66
+
67
+ describe('PktFileUpload', () => {
68
+ const createSeedFileItem = (
69
+ fileId: string,
70
+ name: string,
71
+ type: string = 'application/pdf',
72
+ ): FileItem => ({
73
+ fileId,
74
+ file: new File(['seed'], name, { type }),
75
+ attributes: { targetFilename: name },
76
+ })
77
+
78
+ test('adds files with stable ids and preserves order', async () => {
79
+ const { fileupload } = await createFileUploadTest({ multiple: true })
80
+ const events = recordFilesChanged(fileupload)
81
+
82
+ await selectFiles(fileupload, [
83
+ new File(['a'], 'first.pdf', { type: 'application/pdf' }),
84
+ new File(['b'], 'second.pdf', { type: 'application/pdf' }),
85
+ ])
86
+
87
+ const detail = events.at(-1)!
88
+ expect(detail.reason).toBe('add')
89
+ expect(detail.files).toHaveLength(2)
90
+ expect(detail.files[0].attributes.targetFilename).toBe('first.pdf')
91
+ expect(detail.files[1].attributes.targetFilename).toBe('second.pdf')
92
+ const ids = detail.files.map((item) => item.fileId)
93
+ expect(new Set(ids).size).toBe(2)
94
+
95
+ // Re-render should not change anything user-visible: queue still has both files in order
96
+ fileupload.disabled = true
97
+ await fileupload.updateComplete
98
+ const titles = Array.from(
99
+ fileupload.querySelectorAll('.pkt-fileupload__queue-display__item__title'),
100
+ ).map((el) => {
101
+ const head = el.querySelector('[data-pkt-truncate-part="first"]')?.textContent ?? ''
102
+ const tail = el.querySelector('[data-pkt-truncate-part="tail"]')?.textContent ?? ''
103
+ return (head + tail).trim()
104
+ })
105
+ expect(titles).toEqual(['first.pdf', 'second.pdf'])
106
+ // No new files-changed event should fire from a property toggle
107
+ expect(events).toHaveLength(1)
108
+ })
109
+
110
+ test('remove only affects selected file and keeps other items untouched', async () => {
111
+ const { fileupload } = await createFileUploadTest({ multiple: true })
112
+ const events = recordFilesChanged(fileupload)
113
+
114
+ await selectFiles(fileupload, [
115
+ new File(['a'], 'remove-me.pdf', { type: 'application/pdf' }),
116
+ new File(['b'], 'keep-me.pdf', { type: 'application/pdf' }),
117
+ ])
118
+ const beforeRemove = lastFilesFromEvents(events)
119
+
120
+ const removeButtons = fileupload.querySelectorAll<HTMLButtonElement>(
121
+ 'button[aria-label="Slett fil"]',
122
+ )
123
+ expect(removeButtons.length).toBe(2)
124
+ removeButtons[0].click()
125
+ await fileupload.updateComplete
126
+
127
+ const detail = events.at(-1)!
128
+ expect(detail.reason).toBe('remove')
129
+ expect(detail.changedFileIds).toEqual([beforeRemove[0].fileId])
130
+ expect(detail.files).toHaveLength(1)
131
+ expect(detail.files[0].fileId).toBe(beforeRemove[1].fileId)
132
+ expect(detail.files[0].attributes.targetFilename).toBe('keep-me.pdf')
133
+ })
134
+
135
+ test('update operation keeps ids stable and emits update reason', async () => {
136
+ const { fileupload } = await createFileUploadTest({ multiple: true })
137
+ const events = recordFilesChanged(fileupload)
138
+
139
+ await selectFiles(fileupload, [
140
+ new File(['a'], 'old-name.pdf', { type: 'application/pdf' }),
141
+ new File(['b'], 'other.pdf', { type: 'application/pdf' }),
142
+ ])
143
+ const beforeUpdate = lastFilesFromEvents(events)
144
+
145
+ // updateFileItem is the documented public method for programmatic updates.
146
+ fileupload.updateFileItem(beforeUpdate[0].fileId, {
147
+ attributes: {
148
+ ...beforeUpdate[0].attributes,
149
+ targetFilename: 'new-name.pdf',
150
+ },
151
+ })
152
+ await fileupload.updateComplete
153
+
154
+ const detail = events.at(-1)!
155
+ expect(detail.reason).toBe('update')
156
+ expect(detail.changedFileIds).toEqual([beforeUpdate[0].fileId])
157
+ expect(detail.files).toHaveLength(2)
158
+ expect(detail.files[0].fileId).toBe(beforeUpdate[0].fileId)
159
+ expect(detail.files[0].attributes.targetFilename).toBe('new-name.pdf')
160
+ expect(detail.files[1].fileId).toBe(beforeUpdate[1].fileId)
161
+ })
162
+
163
+ test('controlled mode does not adopt the new list until the parent re-supplies value', async () => {
164
+ const controlledValue: FileItem[] = [
165
+ {
166
+ fileId: 'seed-id',
167
+ attributes: { targetFilename: 'seed.pdf' },
168
+ },
169
+ ]
170
+ const { fileupload } = await createFileUploadTest({ value: controlledValue, multiple: true })
171
+ const events = recordFilesChanged(fileupload)
172
+
173
+ await selectFiles(fileupload, [new File(['a'], 'new.pdf', { type: 'application/pdf' })])
174
+
175
+ // Component emits a `files-changed` with the proposed next list…
176
+ const detail = events.at(-1)!
177
+ expect(detail.reason).toBe('add')
178
+ expect(detail.files).toHaveLength(2)
179
+
180
+ // …but the queue still reflects the controlled value (parent hasn't updated yet).
181
+ const titles = Array.from(
182
+ fileupload.querySelectorAll('.pkt-fileupload__queue-display__item__title'),
183
+ ).map((el) => el.textContent?.trim())
184
+ expect(titles).toEqual(['seed.pdf'])
185
+ })
186
+
187
+ test('renders remove operation for both filename and thumbnail modes', async () => {
188
+ const { fileupload: filenameUpload } = await createFileUploadTest({ itemRenderer: 'filename' })
189
+ await selectFiles(filenameUpload, [
190
+ new File(['a'], 'filename-mode.pdf', { type: 'application/pdf' }),
191
+ ])
192
+ expect(
193
+ filenameUpload.querySelector('button[aria-label="Slett fil"]'),
194
+ ).toBeInTheDocument()
195
+
196
+ const { fileupload: thumbnailUpload } = await createFileUploadTest()
197
+ thumbnailUpload.itemRenderer = 'thumbnail'
198
+ await thumbnailUpload.updateComplete
199
+ await selectFiles(thumbnailUpload, [
200
+ new File(['b'], 'thumbnail-mode.png', { type: 'image/png' }),
201
+ ])
202
+ expect(
203
+ thumbnailUpload.querySelector('.pkt-fileupload__queue-display__item__thumbnail'),
204
+ ).toBeInTheDocument()
205
+ expect(
206
+ thumbnailUpload.querySelector('button[aria-label="Slett fil"]'),
207
+ ).toBeInTheDocument()
208
+ })
209
+
210
+ test('host flow: native input change + remove emits expected files-changed payload', async () => {
211
+ const { fileupload } = await createFileUploadTest({ multiple: true })
212
+ const events: Array<any> = []
213
+ fileupload.addEventListener('files-changed', (event) =>
214
+ events.push((event as CustomEvent).detail),
215
+ )
216
+
217
+ const input = fileupload.querySelector<HTMLInputElement>('input[type="file"]')
218
+ expect(input).toBeInTheDocument()
219
+
220
+ const selectedFiles = [
221
+ new File(['a'], 'dom-first.pdf', { type: 'application/pdf' }),
222
+ new File(['b'], 'dom-second.pdf', { type: 'application/pdf' }),
223
+ ]
224
+
225
+ Object.defineProperty(input as HTMLInputElement, 'files', {
226
+ value: selectedFiles as unknown as FileList,
227
+ configurable: true,
228
+ })
229
+
230
+ input?.dispatchEvent(new Event('change', { bubbles: true }))
231
+ await fileupload.updateComplete
232
+
233
+ const addDetail = events.at(-1)
234
+ expect(addDetail.reason).toBe('add')
235
+ expect(addDetail.files).toHaveLength(2)
236
+
237
+ const removeButtons = fileupload.querySelectorAll<HTMLButtonElement>(
238
+ '.pkt-fileupload__queue-display__item__operation',
239
+ )
240
+ expect(removeButtons).toHaveLength(2)
241
+ removeButtons[0].click()
242
+ await fileupload.updateComplete
243
+
244
+ const removeDetail = events.at(-1)
245
+ expect(removeDetail.reason).toBe('remove')
246
+ expect(removeDetail.files).toHaveLength(1)
247
+ expect(removeDetail.files[0].attributes.targetFilename).toBe('dom-second.pdf')
248
+ })
249
+
250
+ test('disabled mode blocks remove interaction from queue action', async () => {
251
+ const { fileupload } = await createFileUploadTest({ multiple: true })
252
+ const events = recordFilesChanged(fileupload)
253
+ await selectFiles(fileupload, [new File(['a'], 'locked.pdf', { type: 'application/pdf' })])
254
+ expect(events.at(-1)!.reason).toBe('add')
255
+
256
+ fileupload.disabled = true
257
+ await fileupload.updateComplete
258
+
259
+ const removeButton = fileupload.querySelector<HTMLButtonElement>('button[aria-label="Slett fil"]')
260
+ expect(removeButton?.disabled).toBe(true)
261
+ removeButton?.click()
262
+ await fileupload.updateComplete
263
+
264
+ expect(events).toHaveLength(1)
265
+ })
266
+
267
+ test('rejects unsupported formats and shows format error placeholder', async () => {
268
+ const { fileupload } = await createFileUploadTest({
269
+ allowedFormats: ['pdf'],
270
+ formatErrorMessage: 'Kun lovlige formater: {formats}',
271
+ })
272
+ const events = recordFilesChanged(fileupload)
273
+
274
+ await selectFiles(fileupload, [new File(['a'], 'photo.jpg', { type: 'image/jpeg' })])
275
+
276
+ expect(events).toHaveLength(0)
277
+ expect(fileupload.querySelector('pkt-alert')?.textContent).toContain(
278
+ 'Kun lovlige formater: pdf',
279
+ )
280
+ })
281
+
282
+ test('accepts MIME wildcards like image/* in allowedFormats', async () => {
283
+ const { fileupload } = await createFileUploadTest({
284
+ allowedFormats: ['image/*'],
285
+ })
286
+ const events = recordFilesChanged(fileupload)
287
+
288
+ await selectFiles(fileupload, [new File(['a'], 'good.png', { type: 'image/png' })])
289
+ expect(events.at(-1)?.reason).toBe('add')
290
+
291
+ await selectFiles(fileupload, [new File(['b'], 'bad.pdf', { type: 'application/pdf' })])
292
+ expect(fileupload.querySelector('pkt-alert')?.textContent).toContain('Ugyldig filtype')
293
+ // Bad file did not trigger another `files-changed`
294
+ expect(events).toHaveLength(1)
295
+ })
296
+
297
+ test('rejects oversized files and applies {maxSize} placeholder', async () => {
298
+ const { fileupload } = await createFileUploadTest({
299
+ maxFileSize: '500KB',
300
+ sizeErrorMessage: 'Maks størrelse er {maxSize}',
301
+ })
302
+ const events = recordFilesChanged(fileupload)
303
+
304
+ const tooLarge = new File([new Uint8Array(600 * 1024)], 'large.pdf', {
305
+ type: 'application/pdf',
306
+ })
307
+ await selectFiles(fileupload, [tooLarge])
308
+
309
+ expect(events).toHaveLength(0)
310
+ expect(fileupload.querySelector('pkt-alert')?.textContent).toContain('Maks størrelse er 500 KB')
311
+ })
312
+
313
+ test('supports onFileValidation callback for custom validation', async () => {
314
+ const { fileupload } = await createFileUploadTest({
315
+ onFileValidation: (file) =>
316
+ file.name.includes('callback-blocked') ? 'Blokkert av callback' : null,
317
+ })
318
+ const events = recordFilesChanged(fileupload)
319
+
320
+ await selectFiles(fileupload, [
321
+ new File(['a'], 'callback-blocked.pdf', { type: 'application/pdf' }),
322
+ ])
323
+
324
+ expect(events).toHaveLength(0)
325
+ expect(fileupload.querySelector('pkt-alert')?.textContent).toContain('Blokkert av callback')
326
+ })
327
+
328
+ test('supports file-validate event for custom validation', async () => {
329
+ const { fileupload } = await createFileUploadTest({
330
+ onFileValidation: () => null,
331
+ })
332
+ const events = recordFilesChanged(fileupload)
333
+ fileupload.addEventListener('file-validate', (event) => {
334
+ const detail = (event as CustomEvent<{ file: File; errorMessage: string | null }>).detail
335
+ if (detail.file.name.endsWith('.exe')) detail.errorMessage = 'Blokkert av event-hook'
336
+ })
337
+
338
+ await selectFiles(fileupload, [
339
+ new File(['b'], 'event-blocked.exe', { type: 'application/octet-stream' }),
340
+ ])
341
+
342
+ expect(events).toHaveLength(0)
343
+ expect(fileupload.querySelector('pkt-alert')?.textContent).toContain('Blokkert av event-hook')
344
+ })
345
+
346
+ test('dropzone format text follows allowedFormats first, then accept', async () => {
347
+ const { fileupload: noRestrictions } = await createFileUploadTest()
348
+ expect(
349
+ noRestrictions.querySelector('.pkt-fileupload__drop-zone__placeholder__formats'),
350
+ ).not.toBeInTheDocument()
351
+
352
+ const { fileupload: withAllowed } = await createFileUploadTest({
353
+ allowedFormats: ['pdf', 'image/*'],
354
+ })
355
+ const withAllowedText = withAllowed
356
+ .querySelector('.pkt-fileupload__drop-zone__placeholder__formats')
357
+ ?.textContent?.replace(/\s+/g, ' ')
358
+ .trim()
359
+ expect(withAllowedText).toContain('Format: PDF, image/*')
360
+
361
+ const { fileupload: withAccept } = await createFileUploadTest({
362
+ accept: '.png,application/pdf',
363
+ })
364
+ const withAcceptText = withAccept
365
+ .querySelector('.pkt-fileupload__drop-zone__placeholder__formats')
366
+ ?.textContent?.replace(/\s+/g, ' ')
367
+ .trim()
368
+ expect(withAcceptText).toContain('Format: PNG, application/pdf')
369
+ })
370
+
371
+ test('sets required only on native file input in form strategy', async () => {
372
+ const { fileupload: formUpload } = await createFileUploadTest({
373
+ required: true,
374
+ uploadStrategy: 'form',
375
+ })
376
+ const formInput = formUpload.querySelector<HTMLInputElement>('input[type="file"]')
377
+ expect(formInput?.required).toBe(true)
378
+ expect(formInput?.getAttribute('aria-required')).toBe('true')
379
+
380
+ const { fileupload: customUpload } = await createFileUploadTest({
381
+ required: true,
382
+ uploadStrategy: 'custom',
383
+ })
384
+ const customInput = customUpload.querySelector<HTMLInputElement>('input[type="file"]')
385
+ expect(customInput?.required).toBe(false)
386
+ expect(customInput?.getAttribute('aria-required')).toBe('true')
387
+ })
388
+
389
+ test('custom strategy blocks form submit when required and no files are selected', async () => {
390
+ const { fileupload } = await createFileUploadTest({
391
+ required: true,
392
+ uploadStrategy: 'custom',
393
+ name: 'attachments',
394
+ })
395
+ const form = document.createElement('form')
396
+ document.body.appendChild(form)
397
+ form.appendChild(fileupload)
398
+ await fileupload.updateComplete
399
+
400
+ const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
401
+ form.dispatchEvent(submitEvent)
402
+ await fileupload.updateComplete
403
+
404
+ expect(submitEvent.defaultPrevented).toBe(true)
405
+ expect(fileupload.querySelector('pkt-alert')?.textContent).toContain('Du må laste opp minst én fil.')
406
+ })
407
+
408
+ test('custom strategy allows submit when required and at least one file exists', async () => {
409
+ const { fileupload } = await createFileUploadTest({
410
+ required: true,
411
+ uploadStrategy: 'custom',
412
+ multiple: true,
413
+ })
414
+ const form = document.createElement('form')
415
+ document.body.appendChild(form)
416
+ form.appendChild(fileupload)
417
+ await fileupload.updateComplete
418
+
419
+ await selectFiles(fileupload, [new File(['a'], 'ok.pdf', { type: 'application/pdf' })])
420
+
421
+ const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
422
+ form.dispatchEvent(submitEvent)
423
+ await fileupload.updateComplete
424
+
425
+ expect(submitEvent.defaultPrevented).toBe(false)
426
+ expect(fileupload.querySelector('pkt-alert')?.textContent || '').not.toContain(
427
+ 'Du må laste opp minst én fil.',
428
+ )
429
+ })
430
+
431
+ test('rename operation updates target filename and emits update event', async () => {
432
+ const { fileupload } = await createFileUploadTest({
433
+ value: [createSeedFileItem('rename-1', 'old-name.pdf')],
434
+ renameFilesEnabled: true,
435
+ })
436
+ const events = recordFilesChanged(fileupload)
437
+
438
+ const renameButton = fileupload.querySelector<HTMLButtonElement>(
439
+ 'button[aria-label="Rediger filnavn"]',
440
+ )
441
+ expect(renameButton).toBeInTheDocument()
442
+ renameButton?.click()
443
+ await fileupload.updateComplete
444
+
445
+ const renameInput = fileupload.querySelector<HTMLInputElement>(
446
+ '.pkt-fileupload__queue-display__item__rename-input',
447
+ )
448
+ expect(renameInput).toBeInTheDocument()
449
+ if (renameInput) {
450
+ renameInput.value = 'new-name.pdf'
451
+ renameInput.dispatchEvent(
452
+ new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
453
+ )
454
+ }
455
+ await fileupload.updateComplete
456
+
457
+ expect(events.at(-1)?.reason).toBe('update')
458
+ expect(events.at(-1)?.files[0].attributes.targetFilename).toBe('new-name.pdf')
459
+ })
460
+
461
+ test('comment operation supports add and remove comment', async () => {
462
+ const { fileupload } = await createFileUploadTest({ addCommentsEnabled: true })
463
+ await selectFiles(fileupload, [new File(['seed'], 'comment.pdf', { type: 'application/pdf' })])
464
+
465
+ const addCommentButton = Array.from(
466
+ fileupload.querySelectorAll<HTMLButtonElement>('.pkt-fileupload__queue-display__item__operation'),
467
+ ).find((button) => button.textContent?.includes('Legg til kommentar'))
468
+ expect(addCommentButton).toBeInTheDocument()
469
+ addCommentButton?.click()
470
+ await fileupload.updateComplete
471
+
472
+ const commentInput = fileupload.querySelector<HTMLTextAreaElement>(
473
+ '.pkt-fileupload__queue-display__item__comment-input',
474
+ )
475
+ expect(commentInput).toBeInTheDocument()
476
+ if (commentInput) {
477
+ commentInput.value = 'Dette er en kommentar'
478
+ }
479
+
480
+ const saveCommentButton = Array.from(fileupload.querySelectorAll<HTMLButtonElement>('.pkt-btn')).find(
481
+ (button) => button.textContent?.includes('Legg til kommentar'),
482
+ )
483
+ saveCommentButton?.click()
484
+ await fileupload.updateComplete
485
+
486
+ expect(
487
+ fileupload.querySelector('.pkt-fileupload__queue-display__item__comment__text')?.textContent,
488
+ ).toContain('Dette er en kommentar')
489
+
490
+ const deleteCommentButton = fileupload.querySelector<HTMLButtonElement>(
491
+ 'button[aria-label="Slett kommentar"]',
492
+ )
493
+ expect(deleteCommentButton).toBeInTheDocument()
494
+ deleteCommentButton?.click()
495
+ await fileupload.updateComplete
496
+
497
+ expect(fileupload.querySelector('.pkt-fileupload__queue-display__item__comment__text')).not.toBeInTheDocument()
498
+ })
499
+
500
+ test('rename input closes on escape for keyboard users', async () => {
501
+ const { fileupload } = await createFileUploadTest({
502
+ value: [createSeedFileItem('rename-2', 'escape.pdf')],
503
+ renameFilesEnabled: true,
504
+ })
505
+
506
+ const renameButton = fileupload.querySelector<HTMLButtonElement>(
507
+ 'button[aria-label="Rediger filnavn"]',
508
+ )
509
+ renameButton?.click()
510
+ await fileupload.updateComplete
511
+
512
+ const renameInput = fileupload.querySelector<HTMLInputElement>(
513
+ '.pkt-fileupload__queue-display__item__rename-input',
514
+ )
515
+ expect(renameInput).toBeInTheDocument()
516
+ renameInput?.dispatchEvent(
517
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
518
+ )
519
+ await fileupload.updateComplete
520
+
521
+ expect(
522
+ fileupload.querySelector('.pkt-fileupload__queue-display__item__rename-input'),
523
+ ).not.toBeInTheDocument()
524
+ })
525
+
526
+ test('custom operation renders inline ui', async () => {
527
+ const inlineOperation: TQueueItemOperation = {
528
+ id: 'inline-custom',
529
+ title: 'Inline custom',
530
+ renderInlineUI: ({ close }) =>
531
+ html`<span class="custom-inline-ui">Inline UI</span>
532
+ <button type="button" @click=${close}>Lukk</button>`,
533
+ }
534
+
535
+ const { fileupload } = await createFileUploadTest({
536
+ value: [createSeedFileItem('custom-inline-1', 'custom.pdf')],
537
+ extraOperations: [inlineOperation],
538
+ })
539
+
540
+ const button = Array.from(
541
+ fileupload.querySelectorAll<HTMLButtonElement>('.pkt-fileupload__queue-display__item__operation'),
542
+ ).find((candidate) => candidate.textContent?.includes('Inline custom'))
543
+ button?.click()
544
+ await fileupload.updateComplete
545
+
546
+ expect(fileupload.querySelector('.custom-inline-ui')).toBeInTheDocument()
547
+ })
548
+
549
+ test('custom operation renders extended ui', async () => {
550
+ const extendedOperation: TQueueItemOperation = {
551
+ id: 'extended-custom',
552
+ title: 'Extended custom',
553
+ renderExtendedUI: () => html`<div class="custom-extended-ui">Utvidet UI</div>`,
554
+ }
555
+
556
+ const { fileupload } = await createFileUploadTest({
557
+ value: [createSeedFileItem('custom-extended-1', 'custom.pdf')],
558
+ extraOperations: [extendedOperation],
559
+ })
560
+
561
+ const button = Array.from(
562
+ fileupload.querySelectorAll<HTMLButtonElement>('.pkt-fileupload__queue-display__item__operation'),
563
+ ).find((candidate) => candidate.textContent?.includes('Extended custom'))
564
+ button?.click()
565
+ await fileupload.updateComplete
566
+
567
+ expect(fileupload.querySelector('.custom-extended-ui')).toBeInTheDocument()
568
+ })
569
+
570
+ test('custom operation onClick receives the queue context', async () => {
571
+ const seen: Array<{ fileId: string; isActive: boolean }> = []
572
+ const clickOperation: TQueueItemOperation = {
573
+ id: 'click-custom',
574
+ title: 'Klikk',
575
+ onClick: ({ file, isActive }) => seen.push({ fileId: file.fileId, isActive }),
576
+ }
577
+ const { fileupload } = await createFileUploadTest({
578
+ value: [createSeedFileItem('click-1', 'click.pdf')],
579
+ extraOperations: [clickOperation],
580
+ })
581
+
582
+ const button = Array.from(
583
+ fileupload.querySelectorAll<HTMLButtonElement>('.pkt-fileupload__queue-display__item__operation'),
584
+ ).find((candidate) => candidate.textContent?.includes('Klikk'))
585
+ button?.click()
586
+ await fileupload.updateComplete
587
+
588
+ expect(seen).toEqual([{ fileId: 'click-1', isActive: false }])
589
+ })
590
+
591
+ test('renders transfer progress state with progress bar for filename and thumbnail', async () => {
592
+ const seed = createSeedFileItem('progress-1', 'progress.pdf')
593
+ const transfers: TFileTransfer[] = [
594
+ { fileId: 'progress-1', progress: 0.42, showProgress: true },
595
+ ]
596
+
597
+ const { fileupload: filenameUpload } = await createFileUploadTest({
598
+ value: [seed],
599
+ transfers,
600
+ itemRenderer: 'filename',
601
+ })
602
+ expect(
603
+ filenameUpload.querySelector('.pkt-fileupload__queue-display__item--in-progress'),
604
+ ).toBeInTheDocument()
605
+ expect(
606
+ filenameUpload.querySelector('.pkt-fileupload__queue-display__item__progress'),
607
+ ).toBeInTheDocument()
608
+
609
+ const { fileupload: thumbnailUpload } = await createFileUploadTest({
610
+ value: [seed],
611
+ transfers,
612
+ itemRenderer: 'thumbnail',
613
+ })
614
+ expect(
615
+ thumbnailUpload.querySelector('.pkt-fileupload__queue-display__item--in-progress'),
616
+ ).toBeInTheDocument()
617
+ expect(
618
+ thumbnailUpload.querySelector('.pkt-fileupload__queue-display__item__progress'),
619
+ ).toBeInTheDocument()
620
+ })
621
+
622
+ test('renders uploading text state when progress bar is hidden', async () => {
623
+ const seed = createSeedFileItem('uploading-text-1', 'uploading.pdf')
624
+ const { fileupload } = await createFileUploadTest({
625
+ value: [seed],
626
+ transfers: [{ fileId: 'uploading-text-1', progress: 0, showProgress: false }],
627
+ itemRenderer: 'filename',
628
+ })
629
+
630
+ expect(
631
+ fileupload.querySelector('.pkt-fileupload__queue-display__item__loading-text')?.textContent,
632
+ ).toContain('Laster opp')
633
+ })
634
+
635
+ test('renders error transfer state with expected class and message', async () => {
636
+ const seed = createSeedFileItem('error-1', 'failed.pdf')
637
+ const { fileupload } = await createFileUploadTest({
638
+ value: [seed],
639
+ transfers: [
640
+ {
641
+ fileId: 'error-1',
642
+ progress: 'error',
643
+ errorMessage: 'Opplastingen feilet',
644
+ showProgress: true,
645
+ lastProgress: 0.9,
646
+ },
647
+ ],
648
+ })
649
+
650
+ expect(
651
+ fileupload.querySelector('.pkt-fileupload__queue-display__item--error'),
652
+ ).toBeInTheDocument()
653
+ expect(
654
+ fileupload.querySelector('.pkt-fileupload__queue-display__item__error-message')?.textContent,
655
+ ).toContain('Opplastingen feilet')
656
+ })
657
+
658
+ test('cancel button removes queue item and emits transfer-cancelled event', async () => {
659
+ const { fileupload } = await createFileUploadTest({ multiple: true })
660
+
661
+ const transferCancelledEvents: Array<any> = []
662
+ fileupload.addEventListener('transfer-cancelled', (event) =>
663
+ transferCancelledEvents.push((event as CustomEvent).detail),
664
+ )
665
+ const filesChangedEvents = recordFilesChanged(fileupload)
666
+
667
+ await selectFiles(fileupload, [new File(['seed'], 'cancel.pdf', { type: 'application/pdf' })])
668
+ const addedFileId = lastFilesFromEvents(filesChangedEvents)[0].fileId
669
+ fileupload.transfers = [{ fileId: addedFileId, progress: 0.5, showProgress: true }]
670
+ await fileupload.updateComplete
671
+
672
+ const cancelButton = fileupload.querySelector<HTMLButtonElement>(
673
+ 'button[aria-label="Avbryt opplasting"]',
674
+ )
675
+ expect(cancelButton).toBeInTheDocument()
676
+ cancelButton?.click()
677
+ await fileupload.updateComplete
678
+
679
+ expect(filesChangedEvents.at(-1)?.reason).toBe('remove')
680
+ expect(fileupload.querySelectorAll('.pkt-fileupload__queue-display__item')).toHaveLength(0)
681
+ expect(transferCancelledEvents).toHaveLength(1)
682
+ expect(transferCancelledEvents[0].fileId).toBe(addedFileId)
683
+ })
684
+
685
+ test('renders canceled and done transfer state class names', async () => {
686
+ const canceled = createSeedFileItem('cancelled-file', 'cancelled.pdf')
687
+ const done = createSeedFileItem('done-file', 'done.pdf')
688
+ const { fileupload } = await createFileUploadTest({
689
+ value: [canceled, done],
690
+ transfers: [
691
+ { fileId: 'cancelled-file', progress: 'canceled' },
692
+ { fileId: 'done-file', progress: 'done' },
693
+ ],
694
+ itemRenderer: 'thumbnail',
695
+ })
696
+
697
+ expect(
698
+ fileupload.querySelector('.pkt-fileupload__queue-display__item--canceled'),
699
+ ).toBeInTheDocument()
700
+ expect(
701
+ fileupload.querySelector('.pkt-fileupload__queue-display__item--done'),
702
+ ).toBeInTheDocument()
703
+ })
704
+
705
+ test('preview modal opens only for done previewable images in thumbnail mode', async () => {
706
+ const image = createSeedFileItem('preview-image-1', 'preview.png', 'image/png')
707
+ const doc = createSeedFileItem('preview-doc-1', 'not-previewable.pdf', 'application/pdf')
708
+ const { fileupload } = await createFileUploadTest({
709
+ value: [image, doc],
710
+ itemRenderer: 'thumbnail',
711
+ enableImagePreview: true,
712
+ transfers: [
713
+ { fileId: 'preview-image-1', progress: 'done' },
714
+ { fileId: 'preview-doc-1', progress: 'done' },
715
+ ],
716
+ })
717
+
718
+ const previewButtons = fileupload.querySelectorAll<HTMLButtonElement>(
719
+ '.pkt-fileupload__queue-display__item__thumbnail__image-wrapper',
720
+ )
721
+ expect(previewButtons).toHaveLength(2)
722
+ expect(previewButtons[0].disabled).toBe(false)
723
+ expect(previewButtons[1].disabled).toBe(true)
724
+
725
+ previewButtons[0].click()
726
+ await fileupload.updateComplete
727
+
728
+ const modal = fileupload.querySelector<HTMLElement & { open?: boolean }>('pkt-modal')
729
+ expect(modal).toBeInTheDocument()
730
+ expect(modal?.open).toBe(true)
731
+ })
732
+
733
+ test('preview modal supports arrow navigation and escape', async () => {
734
+ const imageOne = createSeedFileItem('preview-nav-1', 'first.png', 'image/png')
735
+ const imageTwo = createSeedFileItem('preview-nav-2', 'second.png', 'image/png')
736
+ const { fileupload } = await createFileUploadTest({
737
+ value: [imageOne, imageTwo],
738
+ itemRenderer: 'thumbnail',
739
+ enableImagePreview: true,
740
+ transfers: [
741
+ { fileId: 'preview-nav-1', progress: 'done' },
742
+ { fileId: 'preview-nav-2', progress: 'done' },
743
+ ],
744
+ })
745
+
746
+ const previewButton = fileupload.querySelector<HTMLButtonElement>(
747
+ '.pkt-fileupload__queue-display__item__thumbnail__image-wrapper',
748
+ )
749
+ previewButton?.click()
750
+ await fileupload.updateComplete
751
+
752
+ const modal = fileupload.querySelector<HTMLElement & { open?: boolean; headingText?: string }>(
753
+ 'pkt-modal',
754
+ )
755
+ expect(modal?.headingText).toBe('first.png')
756
+
757
+ modal?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
758
+ await fileupload.updateComplete
759
+ expect(modal?.headingText).toBe('second.png')
760
+
761
+ modal?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
762
+ await fileupload.updateComplete
763
+ expect(modal?.open).toBe(false)
764
+ })
765
+
766
+ test('preview modal traps tab focus inside modal controls', async () => {
767
+ const imageOne = createSeedFileItem('preview-trap-1', 'first.png', 'image/png')
768
+ const imageTwo = createSeedFileItem('preview-trap-2', 'second.png', 'image/png')
769
+ const { fileupload } = await createFileUploadTest({
770
+ value: [imageOne, imageTwo],
771
+ itemRenderer: 'thumbnail',
772
+ enableImagePreview: true,
773
+ transfers: [
774
+ { fileId: 'preview-trap-1', progress: 'done' },
775
+ { fileId: 'preview-trap-2', progress: 'done' },
776
+ ],
777
+ })
778
+
779
+ const previewButton = fileupload.querySelector<HTMLButtonElement>(
780
+ '.pkt-fileupload__queue-display__item__thumbnail__image-wrapper',
781
+ )
782
+ previewButton?.click()
783
+ await fileupload.updateComplete
784
+
785
+ const closeButton = fileupload.querySelector<HTMLButtonElement>('pkt-modal .pkt-modal__closeButton .pkt-btn')
786
+ const nextButton = fileupload.querySelector<HTMLButtonElement>('.pkt-fileupload__image-preview__nav--next')
787
+ expect(closeButton).toBeInTheDocument()
788
+ expect(nextButton).toBeInTheDocument()
789
+ if (!closeButton || !nextButton) throw new Error('Expected preview modal controls to exist')
790
+
791
+ nextButton.focus()
792
+ nextButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
793
+ expect(document.activeElement).toBe(closeButton)
794
+
795
+ closeButton.focus()
796
+ closeButton.dispatchEvent(
797
+ new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
798
+ )
799
+ expect(document.activeElement).toBe(nextButton)
800
+ })
801
+
802
+ test('falls back to icon when thumbnail or preview image fails', async () => {
803
+ const image = createSeedFileItem('preview-error-1', 'broken.png', 'image/png')
804
+ const { fileupload } = await createFileUploadTest({
805
+ value: [image],
806
+ itemRenderer: 'thumbnail',
807
+ enableImagePreview: true,
808
+ transfers: [{ fileId: 'preview-error-1', progress: 'done' }],
809
+ })
810
+
811
+ const thumbnailImage = fileupload.querySelector<HTMLImageElement>(
812
+ '.pkt-fileupload__queue-display__item__thumbnail__image-wrapper img',
813
+ )
814
+ expect(thumbnailImage).toBeInTheDocument()
815
+ if (!thumbnailImage) throw new Error('Expected thumbnail image to exist')
816
+ thumbnailImage.dispatchEvent(new Event('error'))
817
+ await fileupload.updateComplete
818
+ expect(
819
+ fileupload.querySelector('.pkt-fileupload__queue-display__item__thumbnail .pkt-icon'),
820
+ ).toBeInTheDocument()
821
+
822
+ const previewButton = fileupload.querySelector<HTMLButtonElement>(
823
+ '.pkt-fileupload__queue-display__item__thumbnail__image-wrapper',
824
+ )
825
+ previewButton?.click()
826
+ await fileupload.updateComplete
827
+
828
+ const previewImage = fileupload.querySelector<HTMLImageElement>('.pkt-fileupload__image-preview__image')
829
+ previewImage?.dispatchEvent(new Event('error'))
830
+ await fileupload.updateComplete
831
+ expect(
832
+ fileupload.querySelector('.pkt-fileupload__image-preview .pkt-icon'),
833
+ ).toBeInTheDocument()
834
+ })
835
+
836
+ test('renders externally-supplied errorMessage in the alert and flags the input', async () => {
837
+ const { fileupload } = await createFileUploadTest({
838
+ hasError: true,
839
+ errorMessage: 'Opplastingen feilet hos tjenesten',
840
+ })
841
+
842
+ const alert = fileupload.querySelector('pkt-alert')
843
+ expect(alert?.textContent).toContain('Opplastingen feilet hos tjenesten')
844
+
845
+ const input = fileupload.querySelector<HTMLInputElement>('input[type="file"]')
846
+ expect(input?.getAttribute('aria-invalid')).toBe('true')
847
+ expect(input?.getAttribute('aria-describedby')).toContain(`${fileupload.id}-error`)
848
+ })
849
+
850
+ test('internal validation error still shown when no external error is set', async () => {
851
+ const { fileupload } = await createFileUploadTest({ allowedFormats: ['pdf'] })
852
+
853
+ await selectFiles(fileupload, [new File(['x'], 'bad.png', { type: 'image/png' })])
854
+
855
+ const alert = fileupload.querySelector('pkt-alert')
856
+ expect(alert?.textContent).toContain('Ugyldig filtype')
857
+ expect(
858
+ fileupload.querySelector<HTMLInputElement>('input[type="file"]')?.getAttribute('aria-invalid'),
859
+ ).toBe('true')
860
+ })
861
+
862
+ test('forwards optionalTag and requiredTag to pkt-input-wrapper', async () => {
863
+ const { fileupload: optional } = await createFileUploadTest({
864
+ label: 'Vedlegg',
865
+ optionalTag: true,
866
+ })
867
+ const optionalWrapper = optional.querySelector<HTMLElement & { optionalTag?: boolean }>('pkt-input-wrapper')
868
+ expect(optionalWrapper?.optionalTag).toBe(true)
869
+
870
+ const { fileupload: required } = await createFileUploadTest({
871
+ label: 'Vedlegg',
872
+ requiredTag: true,
873
+ })
874
+ const requiredWrapper = required.querySelector<HTMLElement & { requiredTag?: boolean }>('pkt-input-wrapper')
875
+ expect(requiredWrapper?.requiredTag).toBe(true)
876
+ })
877
+
878
+ test('default id is unique per instance', async () => {
879
+ const { fileupload: first } = await createFileUploadTest()
880
+ const { fileupload: second } = await createFileUploadTest()
881
+
882
+ expect(first.id).toMatch(/^pkt-fileupload-/)
883
+ expect(second.id).toMatch(/^pkt-fileupload-/)
884
+ expect(first.id).not.toBe(second.id)
885
+ })
886
+
887
+ test('renders queue items in the order: in-progress → error → done/queued', async () => {
888
+ const value: FileItem[] = [
889
+ createSeedFileItem('done-1', 'done.pdf'),
890
+ createSeedFileItem('error-1', 'failed.pdf'),
891
+ createSeedFileItem('progress-1', 'uploading.pdf'),
892
+ createSeedFileItem('queued-1', 'queued.pdf'),
893
+ ]
894
+ const transfers: TFileTransfer[] = [
895
+ { fileId: 'done-1', progress: 'done' },
896
+ { fileId: 'error-1', progress: 'error', errorMessage: 'failed' },
897
+ { fileId: 'progress-1', progress: 0.5, showProgress: true },
898
+ { fileId: 'queued-1', progress: 'queued' },
899
+ ]
900
+ const { fileupload } = await createFileUploadTest({
901
+ uploadStrategy: 'custom',
902
+ multiple: true,
903
+ value,
904
+ transfers,
905
+ })
906
+
907
+ const titles = Array.from(
908
+ fileupload.querySelectorAll('.pkt-fileupload__queue-display__item__title'),
909
+ ).map((el) => {
910
+ const head = el.querySelector('[data-pkt-truncate-part="first"]')?.textContent ?? ''
911
+ const tail = el.querySelector('[data-pkt-truncate-part="tail"]')?.textContent ?? ''
912
+ return (head + tail).trim()
913
+ })
914
+ expect(titles).toEqual(['uploading.pdf', 'failed.pdf', 'done.pdf', 'queued.pdf'])
915
+ })
916
+
917
+ test('truncateTail splits long filenames into head + tail spans and leaves short ones whole', async () => {
918
+ const longName = 'a-very-long-filename-that-needs-truncation.pdf'
919
+ const longSeed = {
920
+ fileId: 'truncate-long',
921
+ file: new File(['x'], longName, { type: 'application/pdf' }),
922
+ attributes: { targetFilename: longName },
923
+ }
924
+ const shortSeed = {
925
+ fileId: 'truncate-short',
926
+ file: new File(['x'], 'short.pdf', { type: 'application/pdf' }),
927
+ attributes: { targetFilename: 'short.pdf' },
928
+ }
929
+ const { fileupload } = await createFileUploadTest({
930
+ value: [longSeed, shortSeed],
931
+ truncateTail: 6,
932
+ })
933
+
934
+ const titles = fileupload.querySelectorAll('.pkt-fileupload__queue-display__item__title')
935
+ const longTitle = titles[0] as HTMLElement
936
+ const head = longTitle.querySelector('.pkt-fileupload__queue-display__item__title__head')
937
+ const tail = longTitle.querySelector('.pkt-fileupload__queue-display__item__title__tail')
938
+ expect(head?.textContent).toBe(longName.slice(0, longName.length - 6))
939
+ expect(tail?.textContent).toBe(longName.slice(-6))
940
+ // head + tail reconstruct the full filename (screen readers read both)
941
+ expect((head?.textContent ?? '') + (tail?.textContent ?? '')).toBe(longName)
942
+
943
+ const shortTitle = titles[1] as HTMLElement
944
+ expect(shortTitle.querySelector('.pkt-fileupload__queue-display__item__title__head')).toBeNull()
945
+ expect(
946
+ shortTitle.querySelector('[data-pkt-truncate-part="first"]')?.textContent?.trim(),
947
+ ).toBe('short.pdf')
948
+ })
949
+ })