UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

470 lines (437 loc) 17.6 kB
import { html } from 'lit' import { classMap } from 'lit/directives/class-map.js' import { ifDefined } from 'lit/directives/if-defined.js' import { customElement, state } from 'lit/decorators.js' import { defaultFileUploadStrings } from 'shared-types' import { navigateCyclic, trapTabInside, type IFileAndTransfer } from 'shared-utils/fileupload' import '@/components/alert' import '@/components/icon' import '@/components/input-wrapper' import '@/components/modal' import '@/components/progressbar' import { PktFileUploadBase } from './fileupload-base' import { createCommentOperation, createRemoveOperation, createRenameOperation, } from './fileupload-operations' import { renderFilenameQueueItem, renderThumbnailQueueItem } from './fileupload-renderers' import type { FileItem, IPktFileUpload, TQueueItemOperation, TQueueOperationContext, TQueueOperationLabel, TFileComment, TFileTransfer, TFileUploadItemRenderer, TTransferCancelledDetail, TTransferProgress, TFileValidateDetail, TFileValidator, TUploadStrategy, } from './fileupload-types' export type { FileItem, IPktFileUpload, TQueueItemOperation, TQueueOperationContext, TQueueOperationLabel, TFileComment, TFileTransfer, TFileUploadItemRenderer, TTransferCancelledDetail, TTransferProgress, TFileValidateDetail, TFileValidator, TUploadStrategy, } type FileImageFailureFlags = { thumbnail?: boolean; preview?: boolean } @customElement('pkt-fileupload') export class PktFileUpload extends PktFileUploadBase implements IPktFileUpload { @state() private activeOperationByFileId: Record<string, string | undefined> = {} @state() private isPreviewModalOpen = false @state() private previewCurrentIndex = 0 @state() private failedImageFileIds: Record<string, FileImageFailureFlags> = {} render() { const content = this.renderContent() if (!this.label) return content return html` <pkt-input-wrapper .forId=${`${this.id}-input`} .label=${this.label} .helptext=${this.helptext} ?disabled=${this.disabled} ?hasError=${this.hasEffectiveError} ?optionalTag=${this.optionalTag} ?requiredTag=${this.requiredTag} class=${classMap({ 'pkt-fileupload-wrapper': true, 'pkt-fileupload-wrapper--full-width': this.fullwidth, })} > ${content} </pkt-input-wrapper> ` } private renderContent() { const filesAndTransfers = this.getSortedFilesAndTransfers() const previewableImages = this.getPreviewableImages(filesAndTransfers) const supportedFormatsText = this.getSupportedFormatsText() const helptextId = !this.label && this.helptext ? `${this.id}-helptext` : undefined const errorId = this.hasEffectiveError ? `${this.id}-error` : undefined const describedBy = [helptextId, errorId].filter(Boolean).join(' ') || undefined const isThumbnailView = this.itemRenderer === 'thumbnail' const dropZoneCopy = isThumbnailView ? this.multiple ? { dragInactive: defaultFileUploadStrings.dropZoneDragMultipleThumbnail, dragActive: defaultFileUploadStrings.dropZoneDragActiveMultipleThumbnail, openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogMultipleThumbnail, } : { dragInactive: defaultFileUploadStrings.dropZoneDragSingleThumbnail, dragActive: defaultFileUploadStrings.dropZoneDragActiveSingleThumbnail, openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogSingleThumbnail, } : this.multiple ? { dragInactive: defaultFileUploadStrings.dropZoneDragMultiple, dragActive: defaultFileUploadStrings.dropZoneDragActiveMultiple, openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogMultiple, } : { dragInactive: defaultFileUploadStrings.dropZoneDragSingle, dragActive: defaultFileUploadStrings.dropZoneDragActiveSingle, openFileDialog: defaultFileUploadStrings.dropZoneOpenFileDialogSingle, } return html` <div class=${classMap({ 'pkt-fileupload': true, 'pkt-fileupload--full-width': this.fullwidth, 'pkt-fileupload--error': this.hasEffectiveError, 'pkt-fileupload--disabled': this.disabled, })} aria-disabled=${String(this.disabled)} ?inert=${this.disabled} > <div class="pkt-sr-only" aria-live="polite" aria-atomic="true"> ${this.getSrUploadedMessage()} </div> <div class="pkt-sr-only" aria-live="assertive" aria-atomic="true"> ${this.getSrErrorsMessage()} </div> <div class=${classMap({ 'pkt-fileupload__drop-zone': true, 'pkt-fileupload__drop-zone--drag-active': this.isDragActive, 'pkt-fileupload__drop-zone--disabled': this.disabled, })} @dragover=${this.onDragOver} @dragleave=${this.onDragLeave} @drop=${this.onDrop} @click=${this.onDropZoneClick} > <input id=${`${this.id}-input`} type="file" name=${ifDefined(this.uploadStrategy === 'form' ? this.name : undefined)} ?multiple=${this.multiple} ?disabled=${this.disabled} ?required=${this.required && this.uploadStrategy === 'form'} accept=${this.getResolvedAcceptValue()} aria-label=${ifDefined( this.label ? undefined : this.multiple ? 'Velg filer' : 'Velg fil', )} aria-required=${ifDefined(this.required ? 'true' : undefined)} aria-invalid=${ifDefined(this.hasEffectiveError ? 'true' : undefined)} aria-describedby=${ifDefined(describedBy)} @change=${this.onNativeFileChange} /> ${this.uploadStrategy === 'custom' ? filesAndTransfers.map( (fileItem) => html`<input type="hidden" name=${this.name} value=${fileItem.fileId} />`, ) : null} <div class="pkt-fileupload__drop-zone__placeholder"> <pkt-icon name="attachment" class="pkt-fileupload__drop-zone__placeholder__icon" aria-hidden="true" ></pkt-icon> <p class="pkt-fileupload__drop-zone__placeholder__title"> ${this.isDragActive ? `${dropZoneCopy.dragActive} ...` : html`${dropZoneCopy.dragInactive} <button type="button" class="pkt-fileupload__drop-zone__placeholder__title__open-file-dialog" @click=${this.openFileDialog} > ${dropZoneCopy.openFileDialog} </button>`} </p> ${supportedFormatsText ? html`<p class="pkt-fileupload__drop-zone__placeholder__formats"> ${defaultFileUploadStrings.supportedFormatsPrefix} ${supportedFormatsText} </p>` : null} </div> </div> ${this.hasEffectiveError ? html` <pkt-alert skin="error" role="alert" aria-live="assertive" compact id=${`${this.id}-error`} class="pkt-alert--error pkt-fileupload__error-alert" > ${this.effectiveErrorMessage} </pkt-alert> ` : null} ${filesAndTransfers.length > 0 ? html`<ul class="pkt-fileupload__queue-display"> ${filesAndTransfers.map((file) => this.renderQueueItem(file, previewableImages))} </ul>` : null} ${this.renderPreviewModal(previewableImages)} </div> ` } private renderQueueItem(file: IFileAndTransfer, previewableImages: IFileAndTransfer[]) { const fileSize = typeof file.file?.size === 'number' ? this.formatFileSize(file.file.size) : '' const operations = this.getQueueItemOperations() if (this.itemRenderer === 'thumbnail') { const canOpenPreview = this.canOpenPreview(file) const thumbnailUrl = this.failedImageFileIds[file.fileId]?.thumbnail ? undefined : this.getThumbnailUrl(file) return renderThumbnailQueueItem({ file, inputName: this.name, disabled: this.disabled, fileSize, operations, activeOperationId: this.activeOperationByFileId[file.fileId], onActivateOperation: (fileId, operationId) => this.activateOperation(fileId, operationId), onCloseOperation: (fileId) => this.closeOperation(fileId), getFileAttribute: <T>(fileId: string, name: string) => this.getFileAttribute<T>(fileId, name), setFileAttribute: (fileId: string, name: string, value: unknown) => this.setFileAttribute(fileId, name, value), thumbnailUrl, previewEnabled: this.enableImagePreview, canOpenPreview, transferProgress: file.progress, transferErrorMessage: file.errorMessage, transferShowProgress: file.showProgress, transferLastProgress: file.lastProgress, loadingText: 'Laster opp', truncateTail: this.truncateTail, onCancel: (fileId) => this.cancelTransfer(fileId), onOpenPreview: (fileId) => this.openPreview(fileId, previewableImages), onThumbnailImageError: (fileId) => this.markImageFailed(fileId, 'thumbnail'), }) } return renderFilenameQueueItem({ file, inputName: this.name, disabled: this.disabled, fileSize, operations, activeOperationId: this.activeOperationByFileId[file.fileId], onActivateOperation: (fileId, operationId) => this.activateOperation(fileId, operationId), onCloseOperation: (fileId) => this.closeOperation(fileId), getFileAttribute: <T>(fileId: string, name: string) => this.getFileAttribute<T>(fileId, name), setFileAttribute: (fileId: string, name: string, value: unknown) => this.setFileAttribute(fileId, name, value), transferProgress: file.progress, transferErrorMessage: file.errorMessage, transferShowProgress: file.showProgress, transferLastProgress: file.lastProgress, loadingText: 'Laster opp', truncateTail: this.truncateTail, onCancel: (fileId) => this.cancelTransfer(fileId), }) } private markImageFailed(fileId: string, kind: keyof FileImageFailureFlags): void { this.failedImageFileIds = { ...this.failedImageFileIds, [fileId]: { ...this.failedImageFileIds[fileId], [kind]: true }, } } private getPreviewableImages(filesAndTransfers: IFileAndTransfer[]): IFileAndTransfer[] { if (!this.enableImagePreview) return [] return filesAndTransfers.filter((item) => item.progress === 'done' && this.isImageFile(item)) } private canOpenPreview(file: IFileAndTransfer): boolean { return this.enableImagePreview && file.progress === 'done' && this.isImageFile(file) } private openPreview(fileId: string, previewableImages: IFileAndTransfer[]): void { const index = previewableImages.findIndex((item) => item.fileId === fileId) if (index < 0) return this.previewCurrentIndex = index this.isPreviewModalOpen = true this.failedImageFileIds = { ...this.failedImageFileIds, [fileId]: { ...this.failedImageFileIds[fileId], preview: false }, } } private closePreview = (): void => { this.isPreviewModalOpen = false } private navigatePreview(direction: 'prev' | 'next', previewableImages: IFileAndTransfer[]): void { if (previewableImages.length === 0) return this.previewCurrentIndex = navigateCyclic( this.previewCurrentIndex, direction, previewableImages.length, ) } private onPreviewKeyDown = ( event: KeyboardEvent, previewableImages: IFileAndTransfer[], ): void => { if (!this.isPreviewModalOpen) return if (event.key === 'Tab') { trapTabInside(event, event.currentTarget as HTMLElement | null) return } switch (event.key) { case 'ArrowLeft': event.preventDefault() this.navigatePreview('prev', previewableImages) break case 'ArrowRight': event.preventDefault() this.navigatePreview('next', previewableImages) break case 'Escape': event.preventDefault() this.closePreview() break default: break } } private renderPreviewModal(previewableImages: IFileAndTransfer[]) { if (!this.enableImagePreview || previewableImages.length === 0) return null const currentImage = previewableImages[this.previewCurrentIndex] if (!currentImage) return null const hasMultipleImages = previewableImages.length > 1 const imageUrl = this.getThumbnailUrl(currentImage) const hasImage = !!imageUrl && !this.failedImageFileIds[currentImage.fileId]?.preview const filename = currentImage.attributes?.targetFilename || currentImage.file?.name || 'Forhåndsvisning' return html` <pkt-modal .open=${this.isPreviewModalOpen} .headingText=${filename} closeOnBackdropClick class="pkt-fileupload__image-preview-modal" @close=${this.closePreview} @keydown=${(event: KeyboardEvent) => this.onPreviewKeyDown(event, previewableImages)} > <div class="pkt-fileupload__image-preview"> ${hasMultipleImages ? html`<button type="button" class="pkt-fileupload__image-preview__nav pkt-fileupload__image-preview__nav--prev pkt-btn pkt-btn--medium pkt-btn--secondary pkt-btn--icon-only" aria-label=${`Forrige bilde ${this.previewCurrentIndex} / ${previewableImages.length}`} @click=${() => this.navigatePreview('prev', previewableImages)} > <pkt-icon class="pkt-btn__icon pkt-icon" name="chevron-thin-left" aria-hidden="true" ></pkt-icon> <span class="pkt-btn__text"></span> </button>` : null} <div class="pkt-fileupload__image-preview__content"> ${hasImage ? html`<img src=${imageUrl} alt=${`${filename} - ${this.previewCurrentIndex + 1} av ${previewableImages.length}`} class="pkt-fileupload__image-preview__image" @error=${() => this.markImageFailed(currentImage.fileId, 'preview')} />` : html`<pkt-icon name="picture" class="pkt-fileupload__queue-display__item__icon pkt-icon--medium" aria-hidden="true" ></pkt-icon>`} </div> ${hasMultipleImages ? html`<span class="pkt-fileupload__image-preview__counter"> ${this.previewCurrentIndex + 1} / ${previewableImages.length} </span>` : null} ${hasMultipleImages ? html`<button type="button" class="pkt-fileupload__image-preview__nav pkt-fileupload__image-preview__nav--next pkt-btn pkt-btn--medium pkt-btn--secondary pkt-btn--icon-only" aria-label=${`Neste bilde ${this.previewCurrentIndex + 2} / ${previewableImages.length}`} @click=${() => this.navigatePreview('next', previewableImages)} > <pkt-icon class="pkt-btn__icon pkt-icon" name="chevron-thin-right" aria-hidden="true" ></pkt-icon> <span class="pkt-btn__text"></span> </button>` : null} </div> </pkt-modal> ` } private getQueueItemOperations(): TQueueItemOperation[] { const operations: TQueueItemOperation[] = [] if (this.renameFilesEnabled && this.itemRenderer !== 'thumbnail') { operations.push(createRenameOperation()) } if (this.addCommentsEnabled && this.itemRenderer !== 'thumbnail') { operations.push(createCommentOperation()) } operations.push(...this.extraOperations) operations.push(createRemoveOperation((fileId) => this.removeFileItem(fileId))) return operations } private activateOperation(fileId: string, operationId: string): void { this.activeOperationByFileId = { ...this.activeOperationByFileId, [fileId]: operationId, } } private closeOperation(fileId: string): void { this.activeOperationByFileId = { ...this.activeOperationByFileId, [fileId]: undefined, } } private getFileAttribute<T>(fileId: string, name: string): T | undefined { const currentFile = this.getCurrentFiles().find((item) => item.fileId === fileId) if (!currentFile) return undefined return (currentFile.attributes?.[name] as T | undefined) ?? undefined } private setFileAttribute(fileId: string, name: string, value: unknown): void { const currentFile = this.getCurrentFiles().find((item) => item.fileId === fileId) if (!currentFile) return const nextAttributes = { ...currentFile.attributes, [name]: value, } if (value === undefined) { delete nextAttributes[name] } this.updateFileItem(fileId, { attributes: nextAttributes, }) } }