@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
470 lines (437 loc) • 17.6 kB
text/typescript
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 }
export class PktFileUpload extends PktFileUploadBase implements IPktFileUpload {
private activeOperationByFileId: Record<string, string | undefined> = {}
private isPreviewModalOpen = false
private previewCurrentIndex = 0
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,
})}
=${this.onDragOver}
=${this.onDragLeave}
=${this.onDrop}
=${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)}
=${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"
=${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"
=${this.closePreview}
=${(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}`}
=${() => 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"
=${() => 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}`}
=${() => 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,
})
}
}