@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
770 lines (744 loc) • 22.3 kB
text/typescript
import { html } from 'lit'
import { classMap } from 'lit/directives/class-map.js'
import { ifDefined } from 'lit/directives/if-defined.js'
import { getDisplayFilename, splitFilenameForTruncation } from 'shared-utils/fileupload'
import type {
FileItem,
TQueueItemOperation,
TQueueOperationContext,
TTransferProgress,
} from './fileupload-types'
type TRenderQueueItemProps = {
file: FileItem
inputName: string
disabled: boolean
fileSize: string
thumbnailUrl?: string
previewEnabled?: boolean
canOpenPreview?: boolean
transferProgress: TTransferProgress
transferErrorMessage?: string
transferShowProgress?: boolean
transferLastProgress?: number
loadingText?: string
operations: TQueueItemOperation[]
activeOperationId?: string
truncateTail?: number
onActivateOperation: (fileId: string, operationId: string) => void
onCloseOperation: (fileId: string) => void
getFileAttribute: <T>(fileId: string, name: string) => T | undefined
setFileAttribute: (fileId: string, name: string, value: unknown) => void
onCancel: (fileId: string) => void
onOpenPreview?: (fileId: string) => void
onThumbnailImageError?: (fileId: string) => void
}
/**
* Render a filename with optional middle-truncation. The split-or-not decision
* lives in `splitFilenameForTruncation` (shared with React) so the threshold
* matches across both runtimes.
*/
const renderTruncatedFilename = (filename: string, truncateTail?: number) => {
const split = splitFilenameForTruncation(filename, truncateTail)
if (!split) return html`<span data-pkt-truncate-part="first">${filename}</span>`
return html`<span
class="pkt-fileupload__queue-display__item__title__head"
data-pkt-truncate-part="first"
>${split.head}</span
><span
class="pkt-fileupload__queue-display__item__title__tail"
data-pkt-truncate-part="tail"
>${split.tail}</span
>`
}
const renderTitleWithSize = (filename: string, fileSize: string, truncateTail?: number) => html`
<p class="pkt-fileupload__queue-display__item__title">
${renderTruncatedFilename(filename, truncateTail)}${fileSize
? html`<span class="pkt-fileupload__queue-display__item__filesize"> (${fileSize})</span>`
: null}
</p>
`
const getProgressState = (progress: TTransferProgress): 'in-progress' | 'error' | 'idle' => {
if (typeof progress === 'number') return 'in-progress'
if (progress === 'error') return 'error'
return 'idle'
}
const renderLoadingText = (loadingText?: string) =>
html`<span class="pkt-fileupload__queue-display__item__loading-text" aria-label="Laster opp fil"
>${loadingText ?? 'Laster opp...'}</span
>`
const createOperationContext = ({
operationId,
file,
inputName,
disabled,
isActive,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
}: {
operationId: string
file: FileItem
inputName: string
disabled: boolean
isActive: boolean
onActivateOperation: (fileId: string, operationId: string) => void
onCloseOperation: (fileId: string) => void
getFileAttribute: <T>(fileId: string, name: string) => T | undefined
setFileAttribute: (fileId: string, name: string, value: unknown) => void
}): TQueueOperationContext => ({
file,
inputName,
disabled,
isActive,
activate: () => onActivateOperation(file.fileId, operationId),
close: () => onCloseOperation(file.fileId),
getAttribute: <T>(name: string) => getFileAttribute<T>(file.fileId, name),
setAttribute: (name: string, value: unknown) => setFileAttribute(file.fileId, name, value),
})
const renderTransferInProgress = ({
file,
fileSize,
disabled,
transferProgress,
transferShowProgress,
loadingText,
truncateTail,
onCancel,
iconName = 'document-text',
}: {
file: FileItem
fileSize: string
disabled: boolean
transferProgress: number
transferShowProgress?: boolean
loadingText?: string
truncateTail?: number
onCancel: (fileId: string) => void
iconName?: string
}) => {
const filename = getDisplayFilename(file)
const showProgressBar = transferShowProgress ?? transferProgress !== 0
return html`
<pkt-icon
name=${iconName}
class="pkt-fileupload__queue-display__item__icon pkt-icon--medium"
aria-hidden="true"
></pkt-icon>
${renderTitleWithSize(filename, fileSize, truncateTail)}
<button
type="button"
class="pkt-fileupload__queue-display__item__cancel-button"
aria-label="Avbryt opplasting"
?disabled=${disabled}
@click=${() => onCancel(file.fileId)}
>
Avbryt
</button>
${showProgressBar
? html`
<pkt-progressbar
class="pkt-fileupload__queue-display__item__progress"
valueCurrent=${Math.round(transferProgress * 100)}
valueMax=${100}
statusType="none"
statusPlacement="following"
></pkt-progressbar>
<span class="pkt-fileupload__queue-display__item__percentage" aria-hidden="true"
>${Math.round(transferProgress * 100)}%</span
>
`
: renderLoadingText(loadingText)}
`
}
const renderTransferError = ({
file,
fileSize,
disabled,
transferShowProgress,
transferLastProgress,
transferErrorMessage,
truncateTail,
onCancel,
}: {
file: FileItem
fileSize: string
disabled: boolean
transferShowProgress?: boolean
transferLastProgress?: number
transferErrorMessage?: string
truncateTail?: number
onCancel: (fileId: string) => void
}) => {
const filename = getDisplayFilename(file)
const showProgressError = !!transferShowProgress
const progressValue = Math.round((transferLastProgress ?? 1) * 100)
return html`
<pkt-icon
name="alert-error"
class="pkt-fileupload__queue-display__item__icon pkt-icon--medium"
aria-hidden="true"
></pkt-icon>
${renderTitleWithSize(filename, fileSize, truncateTail)}
<button
type="button"
class="pkt-fileupload__queue-display__item__cancel-button"
aria-label="Fjern fil som feilet under opplasting"
?disabled=${disabled}
@click=${() => onCancel(file.fileId)}
>
Fjern
</button>
${showProgressError
? html`
<pkt-progressbar
class="pkt-fileupload__queue-display__item__progress pkt-fileupload__queue-display__item__progress--error"
valueCurrent=${progressValue}
valueMax=${100}
statusType="none"
statusPlacement="following"
></pkt-progressbar>
`
: null}
${transferErrorMessage
? html`<span class="pkt-fileupload__queue-display__item__error-message"
>${transferErrorMessage}</span
>`
: null}
`
}
const renderOperationButtons = ({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
}: {
file: FileItem
disabled: boolean
inputName: string
operations: TQueueItemOperation[]
activeOperationId?: string
onActivateOperation: (fileId: string, operationId: string) => void
onCloseOperation: (fileId: string) => void
getFileAttribute: <T>(fileId: string, name: string) => T | undefined
setFileAttribute: (fileId: string, name: string, value: unknown) => void
}) => {
const activeOperation = operations.find((operation) => operation.id === activeOperationId)
const hasOperationUIActive = !!(
activeOperation?.renderInlineUI || activeOperation?.renderExtendedUI
)
if (hasOperationUIActive) return null
const operationButtons = operations
.map((operation) => {
const context = createOperationContext({
operationId: operation.id,
file,
inputName,
disabled,
isActive: operation.id === activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})
const title = typeof operation.title === 'function' ? operation.title(file) : operation.title
if (!title) return null
const ariaLabel =
typeof operation.ariaLabel === 'function' ? operation.ariaLabel(file) : operation.ariaLabel
return html`<button
type="button"
class="pkt-fileupload__queue-display__item__operation"
aria-label=${ifDefined(ariaLabel)}
?disabled=${disabled}
@click=${() => {
if (operation.renderInlineUI || operation.renderExtendedUI) {
context.activate()
}
operation.onClick?.(context)
}}
>
${title}
</button>`
})
.filter(Boolean)
if (operationButtons.length === 0) return null
return html`<div class="pkt-fileupload__queue-display__item__actions">${operationButtons}</div>`
}
const renderOperationContent = ({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
}: {
file: FileItem
disabled: boolean
inputName: string
operations: TQueueItemOperation[]
activeOperationId?: string
onActivateOperation: (fileId: string, operationId: string) => void
onCloseOperation: (fileId: string) => void
getFileAttribute: <T>(fileId: string, name: string) => T | undefined
setFileAttribute: (fileId: string, name: string, value: unknown) => void
}) =>
operations.map((operation) => {
if (!operation.renderContent) return null
const context = createOperationContext({
operationId: operation.id,
file,
inputName,
disabled,
isActive: operation.id === activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})
const content = operation.renderContent(context)
if (content === null || content === undefined) {
return null
}
return html`<div class="pkt-fileupload__queue-display__item__operation-content">
${content}
</div>`
})
const renderExpandedOperationContent = ({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
}: {
file: FileItem
disabled: boolean
inputName: string
operations: TQueueItemOperation[]
activeOperationId?: string
onActivateOperation: (fileId: string, operationId: string) => void
onCloseOperation: (fileId: string) => void
getFileAttribute: <T>(fileId: string, name: string) => T | undefined
setFileAttribute: (fileId: string, name: string, value: unknown) => void
}) => {
if (!activeOperationId) return null
const activeOperation = operations.find((operation) => operation.id === activeOperationId)
if (!activeOperation?.renderExtendedUI) return null
const context = createOperationContext({
operationId: activeOperation.id,
file,
inputName,
disabled,
isActive: true,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})
return html`<div class="pkt-fileupload__queue-display__item__expanded-operation-ui">
${activeOperation.renderExtendedUI(context)}
</div>`
}
const renderHiddenOperationInputs = ({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
}: {
file: FileItem
disabled: boolean
inputName: string
operations: TQueueItemOperation[]
activeOperationId?: string
onActivateOperation: (fileId: string, operationId: string) => void
onCloseOperation: (fileId: string) => void
getFileAttribute: <T>(fileId: string, name: string) => T | undefined
setFileAttribute: (fileId: string, name: string, value: unknown) => void
}) =>
operations.map((operation) => {
if (!operation.renderHidden) return null
const context = createOperationContext({
operationId: operation.id,
file,
inputName,
disabled,
isActive: operation.id === activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})
return operation.renderHidden(context)
})
const renderFilenameIdleMain = (file: FileItem, fileSize: string, truncateTail?: number) => {
const filename = getDisplayFilename(file)
return html`
<pkt-icon
name="document-text"
class="pkt-fileupload__queue-display__item__icon pkt-icon--medium"
aria-hidden="true"
></pkt-icon>
${renderTitleWithSize(filename, fileSize, truncateTail)}
`
}
const renderThumbnailIdleMain = ({
file,
thumbnailUrl,
previewEnabled,
canOpenPreview,
truncateTail,
onOpenPreview,
onThumbnailImageError,
}: {
file: FileItem
thumbnailUrl?: string
previewEnabled?: boolean
canOpenPreview?: boolean
truncateTail?: number
onOpenPreview?: (fileId: string) => void
onThumbnailImageError?: (fileId: string) => void
}) => {
const filename = getDisplayFilename(file)
const hasThumbnail = !!thumbnailUrl
const showExpandButton = !!previewEnabled && !!canOpenPreview
const isInteractive = showExpandButton && !!onOpenPreview
return html`
<div
class=${classMap({
'pkt-fileupload__queue-display__item__thumbnail': true,
'pkt-fileupload__queue-display__item__thumbnail--fallback': !hasThumbnail,
})}
>
<button
type="button"
class="pkt-fileupload__queue-display__item__thumbnail__image-wrapper pkt-btn pkt-btn--medium pkt-btn--secondary pkt-btn--label-only"
aria-label=${`Forhåndsvis bilde ${filename}`}
?disabled=${!isInteractive}
@click=${() => {
if (!isInteractive || !onOpenPreview) return
onOpenPreview(file.fileId)
}}
>
<span class="pkt-btn__text">
${showExpandButton
? html`<pkt-icon
name="expand"
class="pkt-fileupload__queue-display__item__thumbnail__expand-icon"
aria-hidden="true"
></pkt-icon>`
: null}
${hasThumbnail
? html`<img
src=${ifDefined(thumbnailUrl)}
alt=${filename}
@error=${() => onThumbnailImageError?.(file.fileId)}
/>`
: html`<pkt-icon
name="document-text"
class="pkt-fileupload__queue-display__item__icon pkt-icon--medium"
aria-hidden="true"
></pkt-icon>`}
</span>
</button>
<span class="pkt-fileupload__queue-display__item__thumbnail__title"
>${renderTruncatedFilename(filename, truncateTail)}</span
>
</div>
`
}
export const renderFilenameQueueItem = ({
file,
inputName,
disabled,
fileSize,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
transferProgress,
transferErrorMessage,
transferShowProgress,
transferLastProgress,
loadingText,
truncateTail,
onCancel,
}: TRenderQueueItemProps) => {
const state = getProgressState(transferProgress)
const activeOperation = operations.find((operation) => operation.id === activeOperationId)
const inlineOperationTemplate = activeOperation?.renderInlineUI
? activeOperation.renderInlineUI(
createOperationContext({
operationId: activeOperation.id,
file,
inputName,
disabled,
isActive: true,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
}),
)
: null
return html`
<li
class=${classMap({
'pkt-fileupload__queue-display__item': true,
'pkt-fileupload__queue-display__item--in-progress': state === 'in-progress',
'pkt-fileupload__queue-display__item--error': state === 'error',
[`pkt-fileupload__queue-display__item--${transferProgress}`]:
typeof transferProgress === 'string',
})}
>
${renderHiddenOperationInputs({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})}
${state === 'in-progress'
? renderTransferInProgress({
file,
fileSize,
disabled,
transferProgress: transferProgress as number,
transferShowProgress,
loadingText,
truncateTail,
onCancel,
})
: state === 'error'
? renderTransferError({
file,
fileSize,
disabled,
transferShowProgress,
transferLastProgress,
transferErrorMessage,
truncateTail,
onCancel,
})
: inlineOperationTemplate
? html`
<pkt-icon
name="document-text"
class="pkt-fileupload__queue-display__item__icon pkt-icon--medium"
aria-hidden="true"
></pkt-icon>
<div class="pkt-fileupload__queue-display__item__inline-ui">
${inlineOperationTemplate}
</div>
`
: renderFilenameIdleMain(file, fileSize, truncateTail)}
${state === 'idle'
? renderOperationButtons({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})
: null}
${state === 'idle'
? renderOperationContent({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})
: null}
${state === 'idle'
? renderExpandedOperationContent({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})
: null}
</li>
`
}
export const renderThumbnailQueueItem = ({
file,
inputName,
disabled,
fileSize,
thumbnailUrl,
previewEnabled,
canOpenPreview,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
transferProgress,
transferErrorMessage,
transferShowProgress,
transferLastProgress,
loadingText,
truncateTail,
onCancel,
onOpenPreview,
onThumbnailImageError,
}: TRenderQueueItemProps) => {
const state = getProgressState(transferProgress)
const activeOperation = operations.find((operation) => operation.id === activeOperationId)
const inlineOperationTemplate = activeOperation?.renderInlineUI
? activeOperation.renderInlineUI(
createOperationContext({
operationId: activeOperation.id,
file,
inputName,
disabled,
isActive: true,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
}),
)
: null
return html`
<li
class=${classMap({
'pkt-fileupload__queue-display__item': true,
'pkt-fileupload__queue-display__item--in-progress': state === 'in-progress',
'pkt-fileupload__queue-display__item--error': state === 'error',
[`pkt-fileupload__queue-display__item--${transferProgress}`]:
typeof transferProgress === 'string',
})}
>
${renderHiddenOperationInputs({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})}
${state === 'in-progress'
? renderTransferInProgress({
file,
fileSize,
disabled,
transferProgress: transferProgress as number,
transferShowProgress,
loadingText,
truncateTail,
onCancel,
iconName: 'picture',
})
: state === 'error'
? renderTransferError({
file,
fileSize,
disabled,
transferShowProgress,
transferLastProgress,
transferErrorMessage,
truncateTail,
onCancel,
})
: inlineOperationTemplate
? html`
<pkt-icon
name="picture"
class="pkt-fileupload__queue-display__item__icon pkt-icon--medium"
aria-hidden="true"
></pkt-icon>
<div class="pkt-fileupload__queue-display__item__inline-ui">
${inlineOperationTemplate}
</div>
`
: renderThumbnailIdleMain({
file,
thumbnailUrl,
previewEnabled,
canOpenPreview,
truncateTail,
onOpenPreview,
onThumbnailImageError,
})}
${state === 'idle'
? renderOperationButtons({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})
: null}
${state === 'idle'
? renderOperationContent({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})
: null}
${state === 'idle'
? renderExpandedOperationContent({
file,
disabled,
inputName,
operations,
activeOperationId,
onActivateOperation,
onCloseOperation,
getFileAttribute,
setFileAttribute,
})
: null}
</li>
`
}