@oslokommune/punkt-elements 16.14.0 → 16.15.1

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 (153) hide show
  1. package/CHANGELOG.md +34 -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-CstFVsy1.cjs → combobox-C4Q6kQEs.cjs} +1 -1
  9. package/dist/{combobox-DtYloYZq.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/converters-Bffq_YDm.cjs +1 -0
  19. package/dist/converters-jauYkvNh.js +1378 -0
  20. package/dist/{datepicker-gO2EuaBX.js → datepicker-BUK1Qanw.js} +22 -21
  21. package/dist/{datepicker-DfSdjIjl.cjs → datepicker-DYd3JCs_.cjs} +10 -10
  22. package/dist/docs/components/fileupload.d.ts +27 -0
  23. package/dist/fileupload-DbmqRkz3.js +1373 -0
  24. package/dist/fileupload-DjUAvBcP.cjs +396 -0
  25. package/dist/fileupload.d.ts +1 -0
  26. package/dist/{header-DpU4HYdj.cjs → header-Dps8o-ZY.cjs} +1 -1
  27. package/dist/{header-BHrgTCxj.js → header-T5O6sfCN.js} +3 -3
  28. package/dist/{helptext-CCBKKLus.js → helptext-C5rG4VkA.js} +2 -2
  29. package/dist/{helptext-D-3QfZv5.cjs → helptext-DQgStO4D.cjs} +1 -1
  30. package/dist/{input-element-Bw1s6NlF.js → input-element-CjebcQd1.js} +3 -3
  31. package/dist/{input-element-B1yxcfz4.cjs → input-element-FGcr5hGd.cjs} +1 -1
  32. package/dist/{input-wrapper-CzS4rUrK.cjs → input-wrapper-B_XFi211.cjs} +1 -1
  33. package/dist/{input-wrapper-XuUnGN_U.js → input-wrapper-DBpMVSxt.js} +4 -4
  34. package/dist/{listbox-DuBgTFvH.js → listbox-0NHJvNjz.js} +2 -2
  35. package/dist/{listbox-DCA2JoPV.cjs → listbox-CcThiNBY.cjs} +1 -1
  36. package/dist/{modal-CBEWrAi2.cjs → modal-B6591sWa.cjs} +1 -1
  37. package/dist/{modal-pPp8nia6.js → modal-DyIC3uhs.js} +1 -1
  38. package/dist/pkt-calendar.cjs +1 -1
  39. package/dist/pkt-calendar.js +1 -1
  40. package/dist/pkt-card.cjs +1 -1
  41. package/dist/pkt-card.js +1 -1
  42. package/dist/pkt-checkbox.cjs +1 -1
  43. package/dist/pkt-checkbox.js +1 -1
  44. package/dist/pkt-combobox.cjs +1 -1
  45. package/dist/pkt-combobox.js +1 -1
  46. package/dist/pkt-consent.cjs +1 -1
  47. package/dist/pkt-consent.js +1 -1
  48. package/dist/pkt-datepicker.cjs +1 -1
  49. package/dist/pkt-datepicker.js +1 -1
  50. package/dist/pkt-fileupload.cjs +1 -0
  51. package/dist/pkt-fileupload.js +2 -0
  52. package/dist/pkt-header.cjs +1 -1
  53. package/dist/pkt-header.js +1 -1
  54. package/dist/pkt-heading.cjs +1 -1
  55. package/dist/pkt-heading.js +1 -1
  56. package/dist/pkt-helptext.cjs +1 -1
  57. package/dist/pkt-helptext.js +1 -1
  58. package/dist/pkt-index.cjs +1 -1
  59. package/dist/pkt-index.js +25 -24
  60. package/dist/pkt-input-wrapper.cjs +1 -1
  61. package/dist/pkt-input-wrapper.js +1 -1
  62. package/dist/pkt-link.cjs +1 -1
  63. package/dist/pkt-link.js +1 -1
  64. package/dist/pkt-linkcard.cjs +1 -1
  65. package/dist/pkt-linkcard.js +1 -1
  66. package/dist/pkt-listbox.cjs +1 -1
  67. package/dist/pkt-listbox.js +1 -1
  68. package/dist/pkt-loader.cjs +1 -1
  69. package/dist/pkt-loader.js +1 -1
  70. package/dist/pkt-messagebox.cjs +1 -1
  71. package/dist/pkt-messagebox.js +1 -1
  72. package/dist/pkt-modal.cjs +1 -1
  73. package/dist/pkt-modal.js +1 -1
  74. package/dist/pkt-progressbar.cjs +1 -1
  75. package/dist/pkt-progressbar.js +1 -1
  76. package/dist/pkt-radiobutton.cjs +1 -1
  77. package/dist/pkt-radiobutton.js +1 -1
  78. package/dist/pkt-select.cjs +1 -1
  79. package/dist/pkt-select.js +1 -1
  80. package/dist/pkt-tabs.cjs +1 -1
  81. package/dist/pkt-tabs.js +1 -1
  82. package/dist/pkt-tag.cjs +1 -1
  83. package/dist/pkt-tag.js +1 -1
  84. package/dist/pkt-textarea.cjs +1 -1
  85. package/dist/pkt-textarea.js +1 -1
  86. package/dist/pkt-textinput.cjs +1 -1
  87. package/dist/pkt-textinput.js +1 -1
  88. package/dist/pkt-timepicker.cjs +1 -1
  89. package/dist/pkt-timepicker.js +1 -1
  90. package/dist/{progressbar-CdXtmOBb.cjs → progressbar-CrwpnIkv.cjs} +1 -1
  91. package/dist/{progressbar-Dy9AiYQz.js → progressbar-uC-0W6o4.js} +1 -1
  92. package/dist/{radiobutton-V_KPbup_.js → radiobutton-B-8-wWFf.js} +1 -1
  93. package/dist/{radiobutton-GOnpUbCW.cjs → radiobutton-DvXr7tDk.cjs} +1 -1
  94. package/dist/{select-Cwzq_O8S.cjs → select-B75wzvyz.cjs} +1 -1
  95. package/dist/{select-Bo-w84pI.js → select-D9B8Ighz.js} +3 -3
  96. package/dist/shared-types/fileupload.d.ts +73 -0
  97. package/dist/shared-types/index.d.ts +2 -0
  98. package/dist/shared-utils/fileupload/announcements.d.ts +11 -0
  99. package/dist/shared-utils/fileupload/filename.d.ts +13 -0
  100. package/dist/shared-utils/fileupload/focus-trap.d.ts +12 -0
  101. package/dist/shared-utils/fileupload/formats.d.ts +30 -0
  102. package/dist/shared-utils/fileupload/index.d.ts +15 -0
  103. package/dist/shared-utils/fileupload/navigation.d.ts +5 -0
  104. package/dist/shared-utils/fileupload/size.d.ts +12 -0
  105. package/dist/shared-utils/fileupload/transfers.d.ts +17 -0
  106. package/dist/shared-utils/fileupload/truncate.d.ts +13 -0
  107. package/dist/shared-utils/fileupload/validation.d.ts +24 -0
  108. package/dist/tests/component-registry.d.ts +2 -0
  109. package/dist/{textarea-DYInBFHP.js → textarea-CIWXMaAD.js} +2 -2
  110. package/dist/{textarea-CbqiuSE8.cjs → textarea-CjqprL6Y.cjs} +1 -1
  111. package/dist/{textinput-Bp-UeGOq.cjs → textinput-B0VWmVIF.cjs} +1 -1
  112. package/dist/{textinput-DO9RldRk.js → textinput-CxzAxM8E.js} +2 -2
  113. package/dist/{timepicker-BpBcPB-r.cjs → timepicker-Cv9juQeB.cjs} +1 -1
  114. package/dist/{timepicker-D_XpQJXa.js → timepicker-DXrpg5C7.js} +2 -2
  115. package/package.json +2 -2
  116. package/src/components/fileupload/fileupload-base.ts +558 -0
  117. package/src/components/fileupload/fileupload-operations.ts +213 -0
  118. package/src/components/fileupload/fileupload-renderers.ts +769 -0
  119. package/src/components/fileupload/fileupload-types.ts +88 -0
  120. package/src/components/fileupload/fileupload.test.ts +949 -0
  121. package/src/components/fileupload/fileupload.ts +469 -0
  122. package/src/components/fileupload/index.ts +34 -0
  123. package/src/components/index.ts +16 -0
  124. package/dist/calendar-BzEtOJla.js +0 -2031
  125. package/dist/calendar-E5jJLt8D.cjs +0 -90
  126. /package/dist/{booleanish-jJLlnr6-.cjs → booleanish-B5EFqFpJ.cjs} +0 -0
  127. /package/dist/{booleanish-BmcOU-43.js → booleanish-CRaKZ75t.js} +0 -0
  128. /package/dist/{consent-DeB_5sWH.js → consent-f_IGyHss.js} +0 -0
  129. /package/dist/{consent-hwu7pp3S.cjs → consent-zh2npq2K.cjs} +0 -0
  130. /package/dist/{dialog-polyfill.esm-1hPr0gl9.js → dialog-polyfill.esm-BmVHGRTX.js} +0 -0
  131. /package/dist/{dialog-polyfill.esm-D3Oinvk0.cjs → dialog-polyfill.esm-CXDsb3l-.cjs} +0 -0
  132. /package/dist/{heading-DiQLPxdf.js → heading-BxvE80bb.js} +0 -0
  133. /package/dist/{heading-BgSWIRii.cjs → heading-DRJTS27k.cjs} +0 -0
  134. /package/dist/{input-wrapper-Dwd-_sKm.cjs → input-wrapper-CYCCx8MP.cjs} +0 -0
  135. /package/dist/{input-wrapper-CDyHTR1V.js → input-wrapper-kw2y6Jp_.js} +0 -0
  136. /package/dist/{link-CyTCpI0k.cjs → link-B8zYc5WK.cjs} +0 -0
  137. /package/dist/{link-CKAFMz9j.js → link-CjJcwcxR.js} +0 -0
  138. /package/dist/{linkcard-BE4-FewK.cjs → linkcard-C4x-NJMi.cjs} +0 -0
  139. /package/dist/{linkcard-B0KOGaGj.js → linkcard-Ddh5R_L-.js} +0 -0
  140. /package/dist/{loader-CX2rsC01.cjs → loader-CGQXAuyM.cjs} +0 -0
  141. /package/dist/{loader-6kFZUErT.js → loader-DeizpX45.js} +0 -0
  142. /package/dist/{messagebox-Din9X53b.cjs → messagebox-Ccu29rDv.cjs} +0 -0
  143. /package/dist/{messagebox-Cwlg5sOr.js → messagebox-DqoPqo4K.js} +0 -0
  144. /package/dist/{pkt-options-controller-CXUGx134.cjs → pkt-options-controller-BPBb_O2K.cjs} +0 -0
  145. /package/dist/{pkt-options-controller-CRrI6WMk.js → pkt-options-controller-DbLrdRwH.js} +0 -0
  146. /package/dist/{repeat-Cepr3469.js → repeat-DV1O43YU.js} +0 -0
  147. /package/dist/{repeat-CqcDYBxr.cjs → repeat-DaDFvx86.cjs} +0 -0
  148. /package/dist/{tabs-Rc9N9HWa.js → tabs-4eWA7CsK.js} +0 -0
  149. /package/dist/{tabs-BfurI6C3.cjs → tabs-RnDtIFv3.cjs} +0 -0
  150. /package/dist/{tag-DLthw5b4.cjs → tag-DWtajZk6.cjs} +0 -0
  151. /package/dist/{tag-VwykmuQR.js → tag-hl59gziI.js} +0 -0
  152. /package/dist/{utils-CUqCKGvp.js → utils-BOOZ0ppt.js} +0 -0
  153. /package/dist/{utils-CjAbIttF.cjs → utils-CM67Oudc.cjs} +0 -0
@@ -0,0 +1,558 @@
1
+ import { PropertyValues } from 'lit'
2
+ import { property, state } from 'lit/decorators.js'
3
+
4
+ import { defaultFileUploadStrings } from 'shared-types'
5
+ import {
6
+ countFileTransferStates,
7
+ formatFileSize,
8
+ getSupportedFormatsText as sharedGetSupportedFormatsText,
9
+ isImageFileLike,
10
+ mergeFilesAndTransfers,
11
+ resolveAcceptAttribute,
12
+ validateFile as sharedValidateFile,
13
+ } from 'shared-utils/fileupload'
14
+
15
+ import { PktElement } from '@/base-elements/element'
16
+ import converters from '../../helpers/converters'
17
+
18
+ import type {
19
+ FileItem,
20
+ IPktFileUpload,
21
+ TQueueItemOperation,
22
+ TFileTransfer,
23
+ TFileUploadItemRenderer,
24
+ TFileValidateDetail,
25
+ TFilesChangedReason,
26
+ TTransferCancelledDetail,
27
+ TTransferProgress,
28
+ TUploadStrategy,
29
+ } from './fileupload-types'
30
+ import { uuidish } from 'shared-utils/utils'
31
+
32
+ export abstract class PktFileUploadBase
33
+ extends PktElement<IPktFileUpload>
34
+ implements IPktFileUpload
35
+ {
36
+ /** Unique id used for internal input + wrapper wiring. Defaults to a per-instance uuid. */
37
+ @property({ type: String }) id: string = `pkt-fileupload-${uuidish()}`
38
+ /** Field name used by native input (`form`) or hidden fileId inputs (`custom`). */
39
+ @property({ type: String }) name: string = 'files'
40
+ /** Optional label shown by `pkt-input-wrapper`. */
41
+ @property({ type: String }) label: string = ''
42
+ /** Optional help text shown under label. */
43
+ @property({ type: String }) helptext: string = ''
44
+ /** Mark input as required in form strategy. */
45
+ @property({ type: Boolean, reflect: true }) required = false
46
+ /** Allow selecting/dropping multiple files. */
47
+ @property({ type: Boolean, reflect: true }) multiple = false
48
+ /** Disable all interaction in drop zone + queue actions. */
49
+ @property({ type: Boolean, reflect: true }) disabled = false
50
+ /** Stretch component width to container. */
51
+ @property({ type: Boolean, reflect: true, attribute: 'fullwidth' }) fullwidth = false
52
+ /** Upload mode: `form` uses native file submit, `custom` emits upload request events per file. */
53
+ @property({ type: String, reflect: true, attribute: 'upload-strategy' })
54
+ uploadStrategy: TUploadStrategy = 'form'
55
+ /** Queue visual mode (`filename` or `thumbnail`). */
56
+ @property({ type: String, reflect: true, attribute: 'item-renderer' })
57
+ itemRenderer: TFileUploadItemRenderer = 'filename'
58
+ /** Native file input accept hint (browser picker filtering). */
59
+ @property({ type: String }) accept: string = ''
60
+ /** Built-in format validation source (csv attribute or array property). */
61
+ @property({ converter: converters.csvToArray, attribute: 'allowed-formats' })
62
+ allowedFormats: string[] = []
63
+ /** Optional custom format validation message. Supports `{formats}` placeholder. */
64
+ @property({ type: String, attribute: 'format-error-message' }) formatErrorMessage = ''
65
+ /** Max allowed file size (bytes or string like `500KB`, `5MB`). */
66
+ @property({ attribute: 'max-file-size' }) maxFileSize?: string | number
67
+ /** Optional custom size validation message. Supports `{maxSize}` placeholder. */
68
+ @property({ type: String, attribute: 'size-error-message' }) sizeErrorMessage = ''
69
+ /** Optional JS callback for custom validation. Property-only on purpose (not HTML-attribute friendly). */
70
+ @property({ attribute: false }) onFileValidation?: (file: File) => string | null
71
+ /** Transfer state list keyed by `fileId` (used for custom upload progress/error/cancel UI). */
72
+ @property({ attribute: false }) transfers: TFileTransfer[] = []
73
+ /** Enables built-in comment operation in queue items (disabled in thumbnail view for parity with React). */
74
+ @property({ type: Boolean, attribute: 'add-comments-enabled' }) addCommentsEnabled = false
75
+ /** Enables built-in rename operation in queue items (disabled in thumbnail view for parity with React). */
76
+ @property({ type: Boolean, attribute: 'rename-files-enabled' }) renameFilesEnabled = false
77
+ /** Custom queue operations (JS property only). Supports inline + expanded operation UIs. */
78
+ @property({ attribute: false }) extraOperations: TQueueItemOperation[] = []
79
+ /** Toggle image thumbnail behavior in queue when renderer supports it. */
80
+ @property({ type: Boolean, reflect: true, attribute: 'enable-image-preview' })
81
+ enableImagePreview = false
82
+ /** External error flag — combines with internal validation errors. */
83
+ @property({ type: Boolean, attribute: 'has-error' }) hasError = false
84
+ /** External error message shown in the alert under the drop zone. */
85
+ @property({ type: String, attribute: 'error-message' }) errorMessage = ''
86
+ /** Show "Valgfritt" tag in the input wrapper. */
87
+ @property({ type: Boolean, attribute: 'optional-tag' }) optionalTag = false
88
+ /** Show "Må fylles ut" tag in the input wrapper. */
89
+ @property({ type: Boolean, attribute: 'required-tag' }) requiredTag = false
90
+ /** Trailing characters to keep when middle-truncating long filenames. Set to `0` to disable. */
91
+ @property({ type: Number, attribute: 'truncate-tail' }) truncateTail: number = 4
92
+ /** Controlled mode source of truth. Parent owns file list and updates this prop from events. */
93
+ @property({ attribute: false }) value?: FileItem[]
94
+ /** Uncontrolled initial file list. Used once during first initialization. */
95
+ @property({ attribute: false }) defaultValue?: FileItem[]
96
+
97
+ @state() protected files: FileItem[] = []
98
+ @state() protected isDragActive = false
99
+ @state() protected validationErrorMessage: string | null = null
100
+ @state() protected addedAnnouncement = ''
101
+ @state() private thumbnailUrls: Record<string, string> = {}
102
+
103
+ private hasInitializedValue = false
104
+ private hasWarnedInvalidValueCombo = false
105
+ private thumbnailFileById = new Map<string, File>()
106
+ private connectedForm: HTMLFormElement | null = null
107
+ private addedAnnouncementTimer: ReturnType<typeof setTimeout> | null = null
108
+ private readonly requiredSelectionMessage = defaultFileUploadStrings.requiredMissing
109
+
110
+ protected get isControlled() {
111
+ return this.value !== undefined
112
+ }
113
+
114
+ /** True if the component itself wants to show an error (internal validation or external prop). */
115
+ protected get hasEffectiveError() {
116
+ return !!this.validationErrorMessage || this.hasError || !!this.errorMessage.trim()
117
+ }
118
+
119
+ /** Message to show in the alert under the drop zone. Internal validation takes priority. */
120
+ protected get effectiveErrorMessage(): string {
121
+ return this.validationErrorMessage ?? this.errorMessage ?? ''
122
+ }
123
+
124
+ protected get hasValidationError() {
125
+ return !!this.validationErrorMessage
126
+ }
127
+
128
+ protected firstUpdated(changedProperties: PropertyValues): void {
129
+ super.firstUpdated?.(changedProperties)
130
+ if (!this.hasInitializedValue) {
131
+ this.files = this.value ?? this.defaultValue ?? []
132
+ this.syncThumbnailUrls(this.files)
133
+ this.hasInitializedValue = true
134
+ }
135
+ if (this.value !== undefined && this.defaultValue !== undefined) {
136
+ this.warnInvalidValueCombo()
137
+ }
138
+ }
139
+
140
+ protected willUpdate(changedProperties: PropertyValues): void {
141
+ super.willUpdate(changedProperties)
142
+ if (!this.hasInitializedValue) return
143
+
144
+ if (
145
+ this.isControlled &&
146
+ (changedProperties.has('value') ||
147
+ changedProperties.has('itemRenderer') ||
148
+ changedProperties.has('enableImagePreview'))
149
+ ) {
150
+ this.files = this.value ?? []
151
+ this.syncThumbnailUrls(this.files)
152
+ }
153
+ }
154
+
155
+ protected updated(changedProperties: PropertyValues): void {
156
+ super.updated(changedProperties)
157
+ if (
158
+ changedProperties.has('uploadStrategy') ||
159
+ changedProperties.has('required') ||
160
+ changedProperties.has('value')
161
+ ) {
162
+ this.syncFormSubmitListener()
163
+ }
164
+ if (
165
+ this.uploadStrategy === 'form' &&
166
+ (changedProperties.has('value') ||
167
+ changedProperties.has('files') ||
168
+ changedProperties.has('uploadStrategy'))
169
+ ) {
170
+ this.syncNativeInputFiles()
171
+ }
172
+ }
173
+
174
+ private syncNativeInputFiles() {
175
+ const input = this.querySelector<HTMLInputElement>(`#${this.id}-input`)
176
+ if (!input || typeof DataTransfer === 'undefined') return
177
+ try {
178
+ const dataTransfer = new DataTransfer()
179
+ for (const item of this.getCurrentFiles()) {
180
+ if (item.file) dataTransfer.items.add(item.file)
181
+ }
182
+ input.files = dataTransfer.files
183
+ } catch {
184
+ // jsdom and some test environments do not implement DataTransfer; not critical.
185
+ }
186
+ }
187
+
188
+ private warnInvalidValueCombo() {
189
+ if (this.hasWarnedInvalidValueCombo) return
190
+
191
+ console.warn(
192
+ 'PktFileUpload: Både value og defaultValue er angitt. Komponenten kan være enten kontrollert eller ukontrollert, ikke begge. value vil bli prioritert.',
193
+ )
194
+ this.hasWarnedInvalidValueCombo = true
195
+ }
196
+
197
+ disconnectedCallback(): void {
198
+ this.teardownFormSubmitListener()
199
+ if (this.addedAnnouncementTimer) {
200
+ clearTimeout(this.addedAnnouncementTimer)
201
+ this.addedAnnouncementTimer = null
202
+ }
203
+ super.disconnectedCallback()
204
+ this.revokeAllThumbnailUrls()
205
+ }
206
+
207
+ private setAddedAnnouncement(message: string) {
208
+ this.addedAnnouncement = message
209
+ if (this.addedAnnouncementTimer) clearTimeout(this.addedAnnouncementTimer)
210
+ this.addedAnnouncementTimer = setTimeout(() => {
211
+ this.addedAnnouncement = ''
212
+ this.addedAnnouncementTimer = null
213
+ }, 1500)
214
+ }
215
+
216
+ connectedCallback(): void {
217
+ super.connectedCallback()
218
+ this.syncFormSubmitListener()
219
+ }
220
+
221
+ protected onNativeFileChange = (event: Event) => {
222
+ const target = event.target as HTMLInputElement
223
+ this.addFiles(target.files ? Array.from(target.files) : [])
224
+ // In custom mode we do not submit native file input values, so we can clear the input
225
+ // to allow selecting the same file again. In form mode we keep native files for form submit.
226
+ if (this.uploadStrategy === 'custom') {
227
+ target.value = ''
228
+ }
229
+ }
230
+
231
+ protected openFileDialog = (event: Event) => {
232
+ event.preventDefault()
233
+ event.stopPropagation()
234
+ if (this.disabled) return
235
+ const input = this.querySelector<HTMLInputElement>(`#${this.id}-input`)
236
+ input?.click()
237
+ }
238
+
239
+ protected onDropZoneClick = (event: MouseEvent) => {
240
+ if (this.disabled) return
241
+ const target = event.target as HTMLElement
242
+ if (target.closest('.pkt-fileupload__drop-zone__placeholder__title__open-file-dialog')) return
243
+ const input = this.querySelector<HTMLInputElement>(`#${this.id}-input`)
244
+ input?.click()
245
+ }
246
+
247
+ protected onDragOver = (event: DragEvent) => {
248
+ event.preventDefault()
249
+ if (this.disabled) return
250
+ this.isDragActive = true
251
+ }
252
+
253
+ protected onDragLeave = () => {
254
+ this.isDragActive = false
255
+ }
256
+
257
+ protected onDrop = (event: DragEvent) => {
258
+ event.preventDefault()
259
+ this.isDragActive = false
260
+ if (this.disabled) return
261
+ const droppedFiles = event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []
262
+ this.addFiles(droppedFiles)
263
+ }
264
+
265
+ protected addFiles(selectedFiles: File[]) {
266
+ if (selectedFiles.length === 0) return
267
+ const normalizedFiles = this.multiple ? selectedFiles : [selectedFiles[0]]
268
+
269
+ for (const file of normalizedFiles) {
270
+ const validationError = this.validateFile(file)
271
+ if (validationError) {
272
+ this.validationErrorMessage = validationError
273
+ return
274
+ }
275
+ }
276
+
277
+ this.validationErrorMessage = null
278
+ const newItems: FileItem[] = normalizedFiles.map((file) => ({
279
+ fileId: uuidish(),
280
+ file,
281
+ attributes: {
282
+ targetFilename: file.name,
283
+ },
284
+ }))
285
+
286
+ this.setAddedAnnouncement(
287
+ newItems.length === 1
288
+ ? defaultFileUploadStrings.srFileAdded(newItems[0].attributes.targetFilename)
289
+ : defaultFileUploadStrings.srFilesAdded(newItems.length),
290
+ )
291
+
292
+ const currentFiles = this.getCurrentFiles()
293
+ const nextFiles = this.multiple ? [...currentFiles, ...newItems] : [newItems[0]]
294
+ this.commitFiles(
295
+ nextFiles,
296
+ 'add',
297
+ newItems.map((item) => item.fileId),
298
+ )
299
+
300
+ if (this.uploadStrategy === 'custom') {
301
+ newItems.forEach((fileItem) => {
302
+ this.dispatchEvent(
303
+ new CustomEvent('file-upload-requested', {
304
+ detail: {
305
+ fileId: fileItem.fileId,
306
+ file: fileItem.file,
307
+ attributes: fileItem.attributes,
308
+ },
309
+ bubbles: true,
310
+ composed: true,
311
+ }),
312
+ )
313
+ })
314
+ }
315
+ }
316
+
317
+ protected getCurrentFiles(): FileItem[] {
318
+ return this.isControlled ? (this.value ?? []) : this.files
319
+ }
320
+
321
+ protected cancelTransfer(fileId: string) {
322
+ if (this.disabled) return
323
+ const currentItem = this.getCurrentFiles().find((item) => item.fileId === fileId)
324
+ if (!currentItem) return
325
+
326
+ this.removeFileItem(fileId)
327
+
328
+ // CE-native deviation from React callback props: emit a bubbling event for host-driven cancel/delete logic.
329
+ this.dispatchEvent(
330
+ new CustomEvent<TTransferCancelledDetail>('transfer-cancelled', {
331
+ detail: {
332
+ fileId: currentItem.fileId,
333
+ file: currentItem.file,
334
+ attributes: currentItem.attributes,
335
+ },
336
+ bubbles: true,
337
+ composed: true,
338
+ }),
339
+ )
340
+ }
341
+
342
+ protected removeFileItem(fileId: string) {
343
+ if (this.disabled) return
344
+ const currentFiles = this.getCurrentFiles()
345
+ const nextFiles = currentFiles.filter((item) => item.fileId !== fileId)
346
+ if (nextFiles.length === currentFiles.length) return
347
+ this.commitFiles(nextFiles, 'remove', [fileId])
348
+ }
349
+
350
+ // CE-native deviation: this is a method-based update hook instead of React callback props.
351
+ // It keeps queue lifecycle explicit without introducing React-specific abstractions.
352
+ public updateFileItem(fileId: string, updates: Partial<FileItem>) {
353
+ const currentFiles = this.getCurrentFiles()
354
+ const nextFiles = currentFiles.map((item) =>
355
+ item.fileId === fileId ? { ...item, ...updates } : item,
356
+ )
357
+ this.commitFiles(nextFiles, 'update', [fileId])
358
+ }
359
+
360
+ protected commitFiles(
361
+ nextFiles: FileItem[],
362
+ reason: TFilesChangedReason,
363
+ changedFileIds?: string[],
364
+ ) {
365
+ if (!this.isControlled) {
366
+ this.files = nextFiles
367
+ this.syncThumbnailUrls(nextFiles)
368
+ }
369
+ if (this.validationErrorMessage === this.requiredSelectionMessage && nextFiles.length > 0) {
370
+ this.validationErrorMessage = null
371
+ }
372
+ this.dispatchEvent(
373
+ new CustomEvent('files-changed', {
374
+ detail: { files: nextFiles, reason, changedFileIds },
375
+ bubbles: true,
376
+ composed: true,
377
+ }),
378
+ )
379
+ }
380
+
381
+ protected getThumbnailUrl(file: FileItem): string | undefined {
382
+ return this.thumbnailUrls[file.fileId]
383
+ }
384
+
385
+ protected getTransferProgress(fileId: string): TTransferProgress {
386
+ const transfer = this.transfers.find((item) => item.fileId === fileId)
387
+ if (transfer) return transfer.progress
388
+ return this.uploadStrategy === 'form' ? 'done' : 'queued'
389
+ }
390
+
391
+ protected getTransferForFile(fileId: string): TFileTransfer | undefined {
392
+ return this.transfers.find((item) => item.fileId === fileId)
393
+ }
394
+
395
+ protected isImageFile(file: FileItem): boolean {
396
+ return isImageFileLike(file)
397
+ }
398
+
399
+ protected formatFileSize(bytes: number): string {
400
+ return formatFileSize(bytes)
401
+ }
402
+
403
+ protected getResolvedAcceptValue(): string {
404
+ return resolveAcceptAttribute(this.accept, this.allowedFormats)
405
+ }
406
+
407
+ protected getSupportedFormatsText(): string {
408
+ return sharedGetSupportedFormatsText(this.allowedFormats, this.accept)
409
+ }
410
+
411
+ protected getSortedFilesAndTransfers() {
412
+ return mergeFilesAndTransfers(this.getCurrentFiles(), this.transfers, this.uploadStrategy)
413
+ }
414
+
415
+ private validateFile(file: File): string | null {
416
+ const baseError = sharedValidateFile(file, {
417
+ allowedFormats: this.allowedFormats,
418
+ maxFileSize: this.maxFileSize,
419
+ formatErrorMessage: this.formatErrorMessage,
420
+ sizeErrorMessage: this.sizeErrorMessage,
421
+ onFileValidation: this.onFileValidation,
422
+ })
423
+ if (baseError) return baseError
424
+
425
+ const eventDetail: TFileValidateDetail = { file, errorMessage: null }
426
+ // CE-native deviation from React: callbacks cannot be passed declaratively in HTML,
427
+ // so we expose an event hook where consumers can set detail.errorMessage.
428
+ const validationEvent = new CustomEvent<TFileValidateDetail>('file-validate', {
429
+ detail: eventDetail,
430
+ bubbles: true,
431
+ composed: true,
432
+ cancelable: true,
433
+ })
434
+ this.dispatchEvent(validationEvent)
435
+
436
+ if (eventDetail.errorMessage) return eventDetail.errorMessage
437
+ if (validationEvent.defaultPrevented) return defaultFileUploadStrings.genericValidationRejection
438
+ return null
439
+ }
440
+
441
+ private syncThumbnailUrls(nextFiles: FileItem[]) {
442
+ const supportsObjectUrl =
443
+ typeof URL !== 'undefined' &&
444
+ typeof URL.createObjectURL === 'function' &&
445
+ typeof URL.revokeObjectURL === 'function'
446
+
447
+ if (!supportsObjectUrl) {
448
+ this.thumbnailUrls = {}
449
+ this.thumbnailFileById.clear()
450
+ return
451
+ }
452
+
453
+ const nextIds = new Set(nextFiles.map((item) => item.fileId))
454
+ const nextUrlMap: Record<string, string> = {}
455
+
456
+ for (const [fileId, url] of Object.entries(this.thumbnailUrls)) {
457
+ if (!nextIds.has(fileId)) {
458
+ URL.revokeObjectURL(url)
459
+ this.thumbnailFileById.delete(fileId)
460
+ }
461
+ }
462
+
463
+ for (const item of nextFiles) {
464
+ const previousUrl = this.thumbnailUrls[item.fileId]
465
+ const previousFile = this.thumbnailFileById.get(item.fileId)
466
+
467
+ if (!item.file || !this.isImageFile(item)) {
468
+ if (previousUrl) {
469
+ URL.revokeObjectURL(previousUrl)
470
+ this.thumbnailFileById.delete(item.fileId)
471
+ }
472
+ continue
473
+ }
474
+
475
+ if (previousUrl && previousFile === item.file) {
476
+ nextUrlMap[item.fileId] = previousUrl
477
+ continue
478
+ }
479
+
480
+ if (previousUrl && previousFile !== item.file) {
481
+ URL.revokeObjectURL(previousUrl)
482
+ }
483
+
484
+ const nextUrl = URL.createObjectURL(item.file)
485
+ this.thumbnailFileById.set(item.fileId, item.file)
486
+ nextUrlMap[item.fileId] = nextUrl
487
+ }
488
+
489
+ this.thumbnailUrls = nextUrlMap
490
+ }
491
+
492
+ private revokeAllThumbnailUrls() {
493
+ if (typeof URL === 'undefined' || typeof URL.revokeObjectURL !== 'function') {
494
+ this.thumbnailUrls = {}
495
+ this.thumbnailFileById.clear()
496
+ return
497
+ }
498
+
499
+ Object.values(this.thumbnailUrls).forEach((url) => URL.revokeObjectURL(url))
500
+ this.thumbnailUrls = {}
501
+ this.thumbnailFileById.clear()
502
+ }
503
+
504
+ /** Polite summary: prefer the transient "file added" announcement, then fall back to upload completion. */
505
+ protected getSrUploadedMessage(): string {
506
+ if (this.addedAnnouncement) return this.addedAnnouncement
507
+ const { totalCount, uploadedCount } = countFileTransferStates(
508
+ this.getCurrentFiles(),
509
+ this.transfers,
510
+ )
511
+ if (totalCount === 0 || uploadedCount === 0) return ''
512
+ return defaultFileUploadStrings.srFilesUploadedOfTotal(
513
+ uploadedCount,
514
+ totalCount,
515
+ defaultFileUploadStrings.fileLabel(totalCount),
516
+ )
517
+ }
518
+
519
+ /** Assertive summary for transfer errors (parity with React `FileUpload`). */
520
+ protected getSrErrorsMessage(): string {
521
+ const { totalCount, failedCount } = countFileTransferStates(
522
+ this.getCurrentFiles(),
523
+ this.transfers,
524
+ )
525
+ if (totalCount === 0 || failedCount === 0) return ''
526
+ return defaultFileUploadStrings.srFilesFailedOfTotal(
527
+ failedCount,
528
+ totalCount,
529
+ defaultFileUploadStrings.fileLabel(totalCount),
530
+ )
531
+ }
532
+
533
+ private syncFormSubmitListener() {
534
+ const nextForm = this.closest('form')
535
+ if (this.connectedForm === nextForm) return
536
+
537
+ this.teardownFormSubmitListener()
538
+ this.connectedForm = nextForm
539
+
540
+ if (this.connectedForm) {
541
+ this.connectedForm.addEventListener('submit', this.onHostFormSubmit)
542
+ }
543
+ }
544
+
545
+ private teardownFormSubmitListener() {
546
+ if (!this.connectedForm) return
547
+ this.connectedForm.removeEventListener('submit', this.onHostFormSubmit)
548
+ this.connectedForm = null
549
+ }
550
+
551
+ private onHostFormSubmit = (event: Event) => {
552
+ if (!this.required || this.uploadStrategy !== 'custom') return
553
+ if (this.getCurrentFiles().length > 0) return
554
+
555
+ this.validationErrorMessage = this.requiredSelectionMessage
556
+ event.preventDefault()
557
+ }
558
+ }