UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

559 lines (488 loc) 20.1 kB
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. */ @property({ type: String }) id: string = `pkt-fileupload-${uuidish()}` /** Field name used by native input (`form`) or hidden fileId inputs (`custom`). */ @property({ type: String }) name: string = 'files' /** Optional label shown by `pkt-input-wrapper`. */ @property({ type: String }) label: string = '' /** Optional help text shown under label. */ @property({ type: String }) helptext: string = '' /** Mark input as required in form strategy. */ @property({ type: Boolean, reflect: true }) required = false /** Allow selecting/dropping multiple files. */ @property({ type: Boolean, reflect: true }) multiple = false /** Disable all interaction in drop zone + queue actions. */ @property({ type: Boolean, reflect: true }) disabled = false /** Stretch component width to container. */ @property({ type: Boolean, reflect: true, attribute: 'fullwidth' }) fullwidth = false /** Upload mode: `form` uses native file submit, `custom` emits upload request events per file. */ @property({ type: String, reflect: true, attribute: 'upload-strategy' }) uploadStrategy: TUploadStrategy = 'form' /** Queue visual mode (`filename` or `thumbnail`). */ @property({ type: String, reflect: true, attribute: 'item-renderer' }) itemRenderer: TFileUploadItemRenderer = 'filename' /** Native file input accept hint (browser picker filtering). */ @property({ type: String }) accept: string = '' /** Built-in format validation source (csv attribute or array property). */ @property({ converter: converters.csvToArray, attribute: 'allowed-formats' }) allowedFormats: string[] = [] /** Optional custom format validation message. Supports `{formats}` placeholder. */ @property({ type: String, attribute: 'format-error-message' }) formatErrorMessage = '' /** Max allowed file size (bytes or string like `500KB`, `5MB`). */ @property({ attribute: 'max-file-size' }) maxFileSize?: string | number /** Optional custom size validation message. Supports `{maxSize}` placeholder. */ @property({ type: String, attribute: 'size-error-message' }) sizeErrorMessage = '' /** Optional JS callback for custom validation. Property-only on purpose (not HTML-attribute friendly). */ @property({ attribute: false }) onFileValidation?: (file: File) => string | null /** Transfer state list keyed by `fileId` (used for custom upload progress/error/cancel UI). */ @property({ attribute: false }) transfers: TFileTransfer[] = [] /** Enables built-in comment operation in queue items (disabled in thumbnail view for parity with React). */ @property({ type: Boolean, attribute: 'add-comments-enabled' }) addCommentsEnabled = false /** Enables built-in rename operation in queue items (disabled in thumbnail view for parity with React). */ @property({ type: Boolean, attribute: 'rename-files-enabled' }) renameFilesEnabled = false /** Custom queue operations (JS property only). Supports inline + expanded operation UIs. */ @property({ attribute: false }) extraOperations: TQueueItemOperation[] = [] /** Toggle image thumbnail behavior in queue when renderer supports it. */ @property({ type: Boolean, reflect: true, attribute: 'enable-image-preview' }) enableImagePreview = false /** External error flag — combines with internal validation errors. */ @property({ type: Boolean, attribute: 'has-error' }) hasError = false /** External error message shown in the alert under the drop zone. */ @property({ type: String, attribute: 'error-message' }) errorMessage = '' /** Show "Valgfritt" tag in the input wrapper. */ @property({ type: Boolean, attribute: 'optional-tag' }) optionalTag = false /** Show "Må fylles ut" tag in the input wrapper. */ @property({ type: Boolean, attribute: 'required-tag' }) requiredTag = false /** Trailing characters to keep when middle-truncating long filenames. Set to `0` to disable. */ @property({ type: Number, attribute: 'truncate-tail' }) truncateTail: number = 4 /** Controlled mode source of truth. Parent owns file list and updates this prop from events. */ @property({ attribute: false }) value?: FileItem[] /** Uncontrolled initial file list. Used once during first initialization. */ @property({ attribute: false }) defaultValue?: FileItem[] @state() protected files: FileItem[] = [] @state() protected isDragActive = false @state() protected validationErrorMessage: string | null = null @state() protected addedAnnouncement = '' @state() 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() } }