@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
559 lines (488 loc) • 20.1 kB
text/typescript
import { PropertyValues } from 'lit'
import { property, state } from 'lit/decorators.js'
import { defaultFileUploadStrings } from 'shared-types'
import {
countFileTransferStates,
formatFileSize,
getSupportedFormatsText as sharedGetSupportedFormatsText,
isImageFileLike,
mergeFilesAndTransfers,
resolveAcceptAttribute,
validateFile as sharedValidateFile,
} from 'shared-utils/fileupload'
import { PktElement } from '@/base-elements/element'
import converters from '../../helpers/converters'
import type {
FileItem,
IPktFileUpload,
TQueueItemOperation,
TFileTransfer,
TFileUploadItemRenderer,
TFileValidateDetail,
TFilesChangedReason,
TTransferCancelledDetail,
TTransferProgress,
TUploadStrategy,
} from './fileupload-types'
import { uuidish } from 'shared-utils/utils'
export abstract class PktFileUploadBase
extends PktElement<IPktFileUpload>
implements IPktFileUpload
{
/** Unique id used for internal input + wrapper wiring. Defaults to a per-instance uuid. */
id: string = `pkt-fileupload-${uuidish()}`
/** Field name used by native input (`form`) or hidden fileId inputs (`custom`). */
name: string = 'files'
/** Optional label shown by `pkt-input-wrapper`. */
label: string = ''
/** Optional help text shown under label. */
helptext: string = ''
/** Mark input as required in form strategy. */
required = false
/** Allow selecting/dropping multiple files. */
multiple = false
/** Disable all interaction in drop zone + queue actions. */
disabled = false
/** Stretch component width to container. */
fullwidth = false
/** Upload mode: `form` uses native file submit, `custom` emits upload request events per file. */
uploadStrategy: TUploadStrategy = 'form'
/** Queue visual mode (`filename` or `thumbnail`). */
itemRenderer: TFileUploadItemRenderer = 'filename'
/** Native file input accept hint (browser picker filtering). */
accept: string = ''
/** Built-in format validation source (csv attribute or array property). */
allowedFormats: string[] = []
/** Optional custom format validation message. Supports `{formats}` placeholder. */
formatErrorMessage = ''
/** Max allowed file size (bytes or string like `500KB`, `5MB`). */
maxFileSize?: string | number
/** Optional custom size validation message. Supports `{maxSize}` placeholder. */
sizeErrorMessage = ''
/** Optional JS callback for custom validation. Property-only on purpose (not HTML-attribute friendly). */
onFileValidation?: (file: File) => string | null
/** Transfer state list keyed by `fileId` (used for custom upload progress/error/cancel UI). */
transfers: TFileTransfer[] = []
/** Enables built-in comment operation in queue items (disabled in thumbnail view for parity with React). */
addCommentsEnabled = false
/** Enables built-in rename operation in queue items (disabled in thumbnail view for parity with React). */
renameFilesEnabled = false
/** Custom queue operations (JS property only). Supports inline + expanded operation UIs. */
extraOperations: TQueueItemOperation[] = []
/** Toggle image thumbnail behavior in queue when renderer supports it. */
enableImagePreview = false
/** External error flag — combines with internal validation errors. */
hasError = false
/** External error message shown in the alert under the drop zone. */
errorMessage = ''
/** Show "Valgfritt" tag in the input wrapper. */
optionalTag = false
/** Show "Må fylles ut" tag in the input wrapper. */
requiredTag = false
/** Trailing characters to keep when middle-truncating long filenames. Set to `0` to disable. */
truncateTail: number = 4
/** Controlled mode source of truth. Parent owns file list and updates this prop from events. */
value?: FileItem[]
/** Uncontrolled initial file list. Used once during first initialization. */
defaultValue?: FileItem[]
protected files: FileItem[] = []
protected isDragActive = false
protected validationErrorMessage: string | null = null
protected addedAnnouncement = ''
private thumbnailUrls: Record<string, string> = {}
private hasInitializedValue = false
private hasWarnedInvalidValueCombo = false
private thumbnailFileById = new Map<string, File>()
private connectedForm: HTMLFormElement | null = null
private addedAnnouncementTimer: ReturnType<typeof setTimeout> | null = null
private readonly requiredSelectionMessage = defaultFileUploadStrings.requiredMissing
protected get isControlled() {
return this.value !== undefined
}
/** True if the component itself wants to show an error (internal validation or external prop). */
protected get hasEffectiveError() {
return !!this.validationErrorMessage || this.hasError || !!this.errorMessage.trim()
}
/** Message to show in the alert under the drop zone. Internal validation takes priority. */
protected get effectiveErrorMessage(): string {
return this.validationErrorMessage ?? this.errorMessage ?? ''
}
protected get hasValidationError() {
return !!this.validationErrorMessage
}
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated?.(changedProperties)
if (!this.hasInitializedValue) {
this.files = this.value ?? this.defaultValue ?? []
this.syncThumbnailUrls(this.files)
this.hasInitializedValue = true
}
if (this.value !== undefined && this.defaultValue !== undefined) {
this.warnInvalidValueCombo()
}
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties)
if (!this.hasInitializedValue) return
if (
this.isControlled &&
(changedProperties.has('value') ||
changedProperties.has('itemRenderer') ||
changedProperties.has('enableImagePreview'))
) {
this.files = this.value ?? []
this.syncThumbnailUrls(this.files)
}
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties)
if (
changedProperties.has('uploadStrategy') ||
changedProperties.has('required') ||
changedProperties.has('value')
) {
this.syncFormSubmitListener()
}
if (
this.uploadStrategy === 'form' &&
(changedProperties.has('value') ||
changedProperties.has('files') ||
changedProperties.has('uploadStrategy'))
) {
this.syncNativeInputFiles()
}
}
private syncNativeInputFiles() {
const input = this.querySelector<HTMLInputElement>(`#${this.id}-input`)
if (!input || typeof DataTransfer === 'undefined') return
try {
const dataTransfer = new DataTransfer()
for (const item of this.getCurrentFiles()) {
if (item.file) dataTransfer.items.add(item.file)
}
input.files = dataTransfer.files
} catch {
// jsdom and some test environments do not implement DataTransfer; not critical.
}
}
private warnInvalidValueCombo() {
if (this.hasWarnedInvalidValueCombo) return
console.warn(
'PktFileUpload: Både value og defaultValue er angitt. Komponenten kan være enten kontrollert eller ukontrollert, ikke begge. value vil bli prioritert.',
)
this.hasWarnedInvalidValueCombo = true
}
disconnectedCallback(): void {
this.teardownFormSubmitListener()
if (this.addedAnnouncementTimer) {
clearTimeout(this.addedAnnouncementTimer)
this.addedAnnouncementTimer = null
}
super.disconnectedCallback()
this.revokeAllThumbnailUrls()
}
private setAddedAnnouncement(message: string) {
this.addedAnnouncement = message
if (this.addedAnnouncementTimer) clearTimeout(this.addedAnnouncementTimer)
this.addedAnnouncementTimer = setTimeout(() => {
this.addedAnnouncement = ''
this.addedAnnouncementTimer = null
}, 1500)
}
connectedCallback(): void {
super.connectedCallback()
this.syncFormSubmitListener()
}
protected onNativeFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
this.addFiles(target.files ? Array.from(target.files) : [])
// In custom mode we do not submit native file input values, so we can clear the input
// to allow selecting the same file again. In form mode we keep native files for form submit.
if (this.uploadStrategy === 'custom') {
target.value = ''
}
}
protected openFileDialog = (event: Event) => {
event.preventDefault()
event.stopPropagation()
if (this.disabled) return
const input = this.querySelector<HTMLInputElement>(`#${this.id}-input`)
input?.click()
}
protected onDropZoneClick = (event: MouseEvent) => {
if (this.disabled) return
const target = event.target as HTMLElement
if (target.closest('.pkt-fileupload__drop-zone__placeholder__title__open-file-dialog')) return
const input = this.querySelector<HTMLInputElement>(`#${this.id}-input`)
input?.click()
}
protected onDragOver = (event: DragEvent) => {
event.preventDefault()
if (this.disabled) return
this.isDragActive = true
}
protected onDragLeave = () => {
this.isDragActive = false
}
protected onDrop = (event: DragEvent) => {
event.preventDefault()
this.isDragActive = false
if (this.disabled) return
const droppedFiles = event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []
this.addFiles(droppedFiles)
}
protected addFiles(selectedFiles: File[]) {
if (selectedFiles.length === 0) return
const normalizedFiles = this.multiple ? selectedFiles : [selectedFiles[0]]
for (const file of normalizedFiles) {
const validationError = this.validateFile(file)
if (validationError) {
this.validationErrorMessage = validationError
return
}
}
this.validationErrorMessage = null
const newItems: FileItem[] = normalizedFiles.map((file) => ({
fileId: uuidish(),
file,
attributes: {
targetFilename: file.name,
},
}))
this.setAddedAnnouncement(
newItems.length === 1
? defaultFileUploadStrings.srFileAdded(newItems[0].attributes.targetFilename)
: defaultFileUploadStrings.srFilesAdded(newItems.length),
)
const currentFiles = this.getCurrentFiles()
const nextFiles = this.multiple ? [...currentFiles, ...newItems] : [newItems[0]]
this.commitFiles(
nextFiles,
'add',
newItems.map((item) => item.fileId),
)
if (this.uploadStrategy === 'custom') {
newItems.forEach((fileItem) => {
this.dispatchEvent(
new CustomEvent('file-upload-requested', {
detail: {
fileId: fileItem.fileId,
file: fileItem.file,
attributes: fileItem.attributes,
},
bubbles: true,
composed: true,
}),
)
})
}
}
protected getCurrentFiles(): FileItem[] {
return this.isControlled ? (this.value ?? []) : this.files
}
protected cancelTransfer(fileId: string) {
if (this.disabled) return
const currentItem = this.getCurrentFiles().find((item) => item.fileId === fileId)
if (!currentItem) return
this.removeFileItem(fileId)
// CE-native deviation from React callback props: emit a bubbling event for host-driven cancel/delete logic.
this.dispatchEvent(
new CustomEvent<TTransferCancelledDetail>('transfer-cancelled', {
detail: {
fileId: currentItem.fileId,
file: currentItem.file,
attributes: currentItem.attributes,
},
bubbles: true,
composed: true,
}),
)
}
protected removeFileItem(fileId: string) {
if (this.disabled) return
const currentFiles = this.getCurrentFiles()
const nextFiles = currentFiles.filter((item) => item.fileId !== fileId)
if (nextFiles.length === currentFiles.length) return
this.commitFiles(nextFiles, 'remove', [fileId])
}
// CE-native deviation: this is a method-based update hook instead of React callback props.
// It keeps queue lifecycle explicit without introducing React-specific abstractions.
public updateFileItem(fileId: string, updates: Partial<FileItem>) {
const currentFiles = this.getCurrentFiles()
const nextFiles = currentFiles.map((item) =>
item.fileId === fileId ? { ...item, ...updates } : item,
)
this.commitFiles(nextFiles, 'update', [fileId])
}
protected commitFiles(
nextFiles: FileItem[],
reason: TFilesChangedReason,
changedFileIds?: string[],
) {
if (!this.isControlled) {
this.files = nextFiles
this.syncThumbnailUrls(nextFiles)
}
if (this.validationErrorMessage === this.requiredSelectionMessage && nextFiles.length > 0) {
this.validationErrorMessage = null
}
this.dispatchEvent(
new CustomEvent('files-changed', {
detail: { files: nextFiles, reason, changedFileIds },
bubbles: true,
composed: true,
}),
)
}
protected getThumbnailUrl(file: FileItem): string | undefined {
return this.thumbnailUrls[file.fileId]
}
protected getTransferProgress(fileId: string): TTransferProgress {
const transfer = this.transfers.find((item) => item.fileId === fileId)
if (transfer) return transfer.progress
return this.uploadStrategy === 'form' ? 'done' : 'queued'
}
protected getTransferForFile(fileId: string): TFileTransfer | undefined {
return this.transfers.find((item) => item.fileId === fileId)
}
protected isImageFile(file: FileItem): boolean {
return isImageFileLike(file)
}
protected formatFileSize(bytes: number): string {
return formatFileSize(bytes)
}
protected getResolvedAcceptValue(): string {
return resolveAcceptAttribute(this.accept, this.allowedFormats)
}
protected getSupportedFormatsText(): string {
return sharedGetSupportedFormatsText(this.allowedFormats, this.accept)
}
protected getSortedFilesAndTransfers() {
return mergeFilesAndTransfers(this.getCurrentFiles(), this.transfers, this.uploadStrategy)
}
private validateFile(file: File): string | null {
const baseError = sharedValidateFile(file, {
allowedFormats: this.allowedFormats,
maxFileSize: this.maxFileSize,
formatErrorMessage: this.formatErrorMessage,
sizeErrorMessage: this.sizeErrorMessage,
onFileValidation: this.onFileValidation,
})
if (baseError) return baseError
const eventDetail: TFileValidateDetail = { file, errorMessage: null }
// CE-native deviation from React: callbacks cannot be passed declaratively in HTML,
// so we expose an event hook where consumers can set detail.errorMessage.
const validationEvent = new CustomEvent<TFileValidateDetail>('file-validate', {
detail: eventDetail,
bubbles: true,
composed: true,
cancelable: true,
})
this.dispatchEvent(validationEvent)
if (eventDetail.errorMessage) return eventDetail.errorMessage
if (validationEvent.defaultPrevented) return defaultFileUploadStrings.genericValidationRejection
return null
}
private syncThumbnailUrls(nextFiles: FileItem[]) {
const supportsObjectUrl =
typeof URL !== 'undefined' &&
typeof URL.createObjectURL === 'function' &&
typeof URL.revokeObjectURL === 'function'
if (!supportsObjectUrl) {
this.thumbnailUrls = {}
this.thumbnailFileById.clear()
return
}
const nextIds = new Set(nextFiles.map((item) => item.fileId))
const nextUrlMap: Record<string, string> = {}
for (const [fileId, url] of Object.entries(this.thumbnailUrls)) {
if (!nextIds.has(fileId)) {
URL.revokeObjectURL(url)
this.thumbnailFileById.delete(fileId)
}
}
for (const item of nextFiles) {
const previousUrl = this.thumbnailUrls[item.fileId]
const previousFile = this.thumbnailFileById.get(item.fileId)
if (!item.file || !this.isImageFile(item)) {
if (previousUrl) {
URL.revokeObjectURL(previousUrl)
this.thumbnailFileById.delete(item.fileId)
}
continue
}
if (previousUrl && previousFile === item.file) {
nextUrlMap[item.fileId] = previousUrl
continue
}
if (previousUrl && previousFile !== item.file) {
URL.revokeObjectURL(previousUrl)
}
const nextUrl = URL.createObjectURL(item.file)
this.thumbnailFileById.set(item.fileId, item.file)
nextUrlMap[item.fileId] = nextUrl
}
this.thumbnailUrls = nextUrlMap
}
private revokeAllThumbnailUrls() {
if (typeof URL === 'undefined' || typeof URL.revokeObjectURL !== 'function') {
this.thumbnailUrls = {}
this.thumbnailFileById.clear()
return
}
Object.values(this.thumbnailUrls).forEach((url) => URL.revokeObjectURL(url))
this.thumbnailUrls = {}
this.thumbnailFileById.clear()
}
/** Polite summary: prefer the transient "file added" announcement, then fall back to upload completion. */
protected getSrUploadedMessage(): string {
if (this.addedAnnouncement) return this.addedAnnouncement
const { totalCount, uploadedCount } = countFileTransferStates(
this.getCurrentFiles(),
this.transfers,
)
if (totalCount === 0 || uploadedCount === 0) return ''
return defaultFileUploadStrings.srFilesUploadedOfTotal(
uploadedCount,
totalCount,
defaultFileUploadStrings.fileLabel(totalCount),
)
}
/** Assertive summary for transfer errors (parity with React `FileUpload`). */
protected getSrErrorsMessage(): string {
const { totalCount, failedCount } = countFileTransferStates(
this.getCurrentFiles(),
this.transfers,
)
if (totalCount === 0 || failedCount === 0) return ''
return defaultFileUploadStrings.srFilesFailedOfTotal(
failedCount,
totalCount,
defaultFileUploadStrings.fileLabel(totalCount),
)
}
private syncFormSubmitListener() {
const nextForm = this.closest('form')
if (this.connectedForm === nextForm) return
this.teardownFormSubmitListener()
this.connectedForm = nextForm
if (this.connectedForm) {
this.connectedForm.addEventListener('submit', this.onHostFormSubmit)
}
}
private teardownFormSubmitListener() {
if (!this.connectedForm) return
this.connectedForm.removeEventListener('submit', this.onHostFormSubmit)
this.connectedForm = null
}
private onHostFormSubmit = (event: Event) => {
if (!this.required || this.uploadStrategy !== 'custom') return
if (this.getCurrentFiles().length > 0) return
this.validationErrorMessage = this.requiredSelectionMessage
event.preventDefault()
}
}