@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
950 lines (810 loc) • 37.2 kB
text/typescript
import '@testing-library/jest-dom'
import { html } from 'lit'
import { createElementTest, BaseTestConfig } from '@/tests/test-framework'
import { CustomElementFor } from '@/tests/component-registry'
import './fileupload'
import { IPktFileUpload, FileItem, TFileTransfer, TQueueItemOperation } from './fileupload-types'
interface FileUploadTestConfig extends Partial<IPktFileUpload>, BaseTestConfig {}
const createFileUploadTest = async (config: FileUploadTestConfig = {}) => {
// Default empty light-DOM child: stray "Test content" breaks Lit render on `pkt-fileupload` (createRenderRoot = this).
const { content = '', ...attributeConfig } = config
const { container, element } = await createElementTest<
CustomElementFor<'pkt-fileupload'>,
BaseTestConfig
>('pkt-fileupload', { content })
Object.entries(attributeConfig).forEach(([key, value]) => {
;(element as any)[key] = value
})
await element.updateComplete
return {
container,
fileupload: element,
}
}
/**
* Drive the component through its public input — same pattern as React's
* `fireEvent.change(fileInput, { target: { files } })`. Tests should prefer
* this over poking `.addFiles()` directly so the assertions stay close to
* the user-visible flow.
*/
const selectFiles = async (
fileupload: CustomElementFor<'pkt-fileupload'>,
files: File[],
): Promise<void> => {
const input = fileupload.querySelector<HTMLInputElement>('input[type="file"]')
if (!input) throw new Error('Expected pkt-fileupload to render a native file input')
Object.defineProperty(input, 'files', {
configurable: true,
value: files,
})
input.dispatchEvent(new Event('change', { bubbles: true }))
await fileupload.updateComplete
}
/** Convenience to track every `files-changed` event the component emits. */
const recordFilesChanged = (fileupload: CustomElementFor<'pkt-fileupload'>) => {
const events: Array<{ files: FileItem[]; reason: string; changedFileIds?: string[] }> = []
fileupload.addEventListener('files-changed', (event) => {
events.push((event as CustomEvent).detail)
})
return events
}
/** Returns the most recent file list as observed via `files-changed` events. */
const lastFilesFromEvents = (
events: Array<{ files: FileItem[] }>,
): FileItem[] => (events.length === 0 ? [] : events[events.length - 1].files)
afterEach(() => {
document.body.innerHTML = ''
})
describe('PktFileUpload', () => {
const createSeedFileItem = (
fileId: string,
name: string,
type: string = 'application/pdf',
): FileItem => ({
fileId,
file: new File(['seed'], name, { type }),
attributes: { targetFilename: name },
})
test('adds files with stable ids and preserves order', async () => {
const { fileupload } = await createFileUploadTest({ multiple: true })
const events = recordFilesChanged(fileupload)
await selectFiles(fileupload, [
new File(['a'], 'first.pdf', { type: 'application/pdf' }),
new File(['b'], 'second.pdf', { type: 'application/pdf' }),
])
const detail = events.at(-1)!
expect(detail.reason).toBe('add')
expect(detail.files).toHaveLength(2)
expect(detail.files[0].attributes.targetFilename).toBe('first.pdf')
expect(detail.files[1].attributes.targetFilename).toBe('second.pdf')
const ids = detail.files.map((item) => item.fileId)
expect(new Set(ids).size).toBe(2)
// Re-render should not change anything user-visible: queue still has both files in order
fileupload.disabled = true
await fileupload.updateComplete
const titles = Array.from(
fileupload.querySelectorAll('.pkt-fileupload__queue-display__item__title'),
).map((el) => {
const head = el.querySelector('[data-pkt-truncate-part="first"]')?.textContent ?? ''
const tail = el.querySelector('[data-pkt-truncate-part="tail"]')?.textContent ?? ''
return (head + tail).trim()
})
expect(titles).toEqual(['first.pdf', 'second.pdf'])
// No new files-changed event should fire from a property toggle
expect(events).toHaveLength(1)
})
test('remove only affects selected file and keeps other items untouched', async () => {
const { fileupload } = await createFileUploadTest({ multiple: true })
const events = recordFilesChanged(fileupload)
await selectFiles(fileupload, [
new File(['a'], 'remove-me.pdf', { type: 'application/pdf' }),
new File(['b'], 'keep-me.pdf', { type: 'application/pdf' }),
])
const beforeRemove = lastFilesFromEvents(events)
const removeButtons = fileupload.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Slett fil"]',
)
expect(removeButtons.length).toBe(2)
removeButtons[0].click()
await fileupload.updateComplete
const detail = events.at(-1)!
expect(detail.reason).toBe('remove')
expect(detail.changedFileIds).toEqual([beforeRemove[0].fileId])
expect(detail.files).toHaveLength(1)
expect(detail.files[0].fileId).toBe(beforeRemove[1].fileId)
expect(detail.files[0].attributes.targetFilename).toBe('keep-me.pdf')
})
test('update operation keeps ids stable and emits update reason', async () => {
const { fileupload } = await createFileUploadTest({ multiple: true })
const events = recordFilesChanged(fileupload)
await selectFiles(fileupload, [
new File(['a'], 'old-name.pdf', { type: 'application/pdf' }),
new File(['b'], 'other.pdf', { type: 'application/pdf' }),
])
const beforeUpdate = lastFilesFromEvents(events)
// updateFileItem is the documented public method for programmatic updates.
fileupload.updateFileItem(beforeUpdate[0].fileId, {
attributes: {
...beforeUpdate[0].attributes,
targetFilename: 'new-name.pdf',
},
})
await fileupload.updateComplete
const detail = events.at(-1)!
expect(detail.reason).toBe('update')
expect(detail.changedFileIds).toEqual([beforeUpdate[0].fileId])
expect(detail.files).toHaveLength(2)
expect(detail.files[0].fileId).toBe(beforeUpdate[0].fileId)
expect(detail.files[0].attributes.targetFilename).toBe('new-name.pdf')
expect(detail.files[1].fileId).toBe(beforeUpdate[1].fileId)
})
test('controlled mode does not adopt the new list until the parent re-supplies value', async () => {
const controlledValue: FileItem[] = [
{
fileId: 'seed-id',
attributes: { targetFilename: 'seed.pdf' },
},
]
const { fileupload } = await createFileUploadTest({ value: controlledValue, multiple: true })
const events = recordFilesChanged(fileupload)
await selectFiles(fileupload, [new File(['a'], 'new.pdf', { type: 'application/pdf' })])
// Component emits a `files-changed` with the proposed next list…
const detail = events.at(-1)!
expect(detail.reason).toBe('add')
expect(detail.files).toHaveLength(2)
// …but the queue still reflects the controlled value (parent hasn't updated yet).
const titles = Array.from(
fileupload.querySelectorAll('.pkt-fileupload__queue-display__item__title'),
).map((el) => el.textContent?.trim())
expect(titles).toEqual(['seed.pdf'])
})
test('renders remove operation for both filename and thumbnail modes', async () => {
const { fileupload: filenameUpload } = await createFileUploadTest({ itemRenderer: 'filename' })
await selectFiles(filenameUpload, [
new File(['a'], 'filename-mode.pdf', { type: 'application/pdf' }),
])
expect(
filenameUpload.querySelector('button[aria-label="Slett fil"]'),
).toBeInTheDocument()
const { fileupload: thumbnailUpload } = await createFileUploadTest()
thumbnailUpload.itemRenderer = 'thumbnail'
await thumbnailUpload.updateComplete
await selectFiles(thumbnailUpload, [
new File(['b'], 'thumbnail-mode.png', { type: 'image/png' }),
])
expect(
thumbnailUpload.querySelector('.pkt-fileupload__queue-display__item__thumbnail'),
).toBeInTheDocument()
expect(
thumbnailUpload.querySelector('button[aria-label="Slett fil"]'),
).toBeInTheDocument()
})
test('host flow: native input change + remove emits expected files-changed payload', async () => {
const { fileupload } = await createFileUploadTest({ multiple: true })
const events: Array<any> = []
fileupload.addEventListener('files-changed', (event) =>
events.push((event as CustomEvent).detail),
)
const input = fileupload.querySelector<HTMLInputElement>('input[type="file"]')
expect(input).toBeInTheDocument()
const selectedFiles = [
new File(['a'], 'dom-first.pdf', { type: 'application/pdf' }),
new File(['b'], 'dom-second.pdf', { type: 'application/pdf' }),
]
Object.defineProperty(input as HTMLInputElement, 'files', {
value: selectedFiles as unknown as FileList,
configurable: true,
})
input?.dispatchEvent(new Event('change', { bubbles: true }))
await fileupload.updateComplete
const addDetail = events.at(-1)
expect(addDetail.reason).toBe('add')
expect(addDetail.files).toHaveLength(2)
const removeButtons = fileupload.querySelectorAll<HTMLButtonElement>(
'.pkt-fileupload__queue-display__item__operation',
)
expect(removeButtons).toHaveLength(2)
removeButtons[0].click()
await fileupload.updateComplete
const removeDetail = events.at(-1)
expect(removeDetail.reason).toBe('remove')
expect(removeDetail.files).toHaveLength(1)
expect(removeDetail.files[0].attributes.targetFilename).toBe('dom-second.pdf')
})
test('disabled mode blocks remove interaction from queue action', async () => {
const { fileupload } = await createFileUploadTest({ multiple: true })
const events = recordFilesChanged(fileupload)
await selectFiles(fileupload, [new File(['a'], 'locked.pdf', { type: 'application/pdf' })])
expect(events.at(-1)!.reason).toBe('add')
fileupload.disabled = true
await fileupload.updateComplete
const removeButton = fileupload.querySelector<HTMLButtonElement>('button[aria-label="Slett fil"]')
expect(removeButton?.disabled).toBe(true)
removeButton?.click()
await fileupload.updateComplete
expect(events).toHaveLength(1)
})
test('rejects unsupported formats and shows format error placeholder', async () => {
const { fileupload } = await createFileUploadTest({
allowedFormats: ['pdf'],
formatErrorMessage: 'Kun lovlige formater: {formats}',
})
const events = recordFilesChanged(fileupload)
await selectFiles(fileupload, [new File(['a'], 'photo.jpg', { type: 'image/jpeg' })])
expect(events).toHaveLength(0)
expect(fileupload.querySelector('pkt-alert')?.textContent).toContain(
'Kun lovlige formater: pdf',
)
})
test('accepts MIME wildcards like image/* in allowedFormats', async () => {
const { fileupload } = await createFileUploadTest({
allowedFormats: ['image/*'],
})
const events = recordFilesChanged(fileupload)
await selectFiles(fileupload, [new File(['a'], 'good.png', { type: 'image/png' })])
expect(events.at(-1)?.reason).toBe('add')
await selectFiles(fileupload, [new File(['b'], 'bad.pdf', { type: 'application/pdf' })])
expect(fileupload.querySelector('pkt-alert')?.textContent).toContain('Ugyldig filtype')
// Bad file did not trigger another `files-changed`
expect(events).toHaveLength(1)
})
test('rejects oversized files and applies {maxSize} placeholder', async () => {
const { fileupload } = await createFileUploadTest({
maxFileSize: '500KB',
sizeErrorMessage: 'Maks størrelse er {maxSize}',
})
const events = recordFilesChanged(fileupload)
const tooLarge = new File([new Uint8Array(600 * 1024)], 'large.pdf', {
type: 'application/pdf',
})
await selectFiles(fileupload, [tooLarge])
expect(events).toHaveLength(0)
expect(fileupload.querySelector('pkt-alert')?.textContent).toContain('Maks størrelse er 500 KB')
})
test('supports onFileValidation callback for custom validation', async () => {
const { fileupload } = await createFileUploadTest({
onFileValidation: (file) =>
file.name.includes('callback-blocked') ? 'Blokkert av callback' : null,
})
const events = recordFilesChanged(fileupload)
await selectFiles(fileupload, [
new File(['a'], 'callback-blocked.pdf', { type: 'application/pdf' }),
])
expect(events).toHaveLength(0)
expect(fileupload.querySelector('pkt-alert')?.textContent).toContain('Blokkert av callback')
})
test('supports file-validate event for custom validation', async () => {
const { fileupload } = await createFileUploadTest({
onFileValidation: () => null,
})
const events = recordFilesChanged(fileupload)
fileupload.addEventListener('file-validate', (event) => {
const detail = (event as CustomEvent<{ file: File; errorMessage: string | null }>).detail
if (detail.file.name.endsWith('.exe')) detail.errorMessage = 'Blokkert av event-hook'
})
await selectFiles(fileupload, [
new File(['b'], 'event-blocked.exe', { type: 'application/octet-stream' }),
])
expect(events).toHaveLength(0)
expect(fileupload.querySelector('pkt-alert')?.textContent).toContain('Blokkert av event-hook')
})
test('dropzone format text follows allowedFormats first, then accept', async () => {
const { fileupload: noRestrictions } = await createFileUploadTest()
expect(
noRestrictions.querySelector('.pkt-fileupload__drop-zone__placeholder__formats'),
).not.toBeInTheDocument()
const { fileupload: withAllowed } = await createFileUploadTest({
allowedFormats: ['pdf', 'image/*'],
})
const withAllowedText = withAllowed
.querySelector('.pkt-fileupload__drop-zone__placeholder__formats')
?.textContent?.replace(/\s+/g, ' ')
.trim()
expect(withAllowedText).toContain('Format: PDF, image/*')
const { fileupload: withAccept } = await createFileUploadTest({
accept: '.png,application/pdf',
})
const withAcceptText = withAccept
.querySelector('.pkt-fileupload__drop-zone__placeholder__formats')
?.textContent?.replace(/\s+/g, ' ')
.trim()
expect(withAcceptText).toContain('Format: PNG, application/pdf')
})
test('sets required only on native file input in form strategy', async () => {
const { fileupload: formUpload } = await createFileUploadTest({
required: true,
uploadStrategy: 'form',
})
const formInput = formUpload.querySelector<HTMLInputElement>('input[type="file"]')
expect(formInput?.required).toBe(true)
expect(formInput?.getAttribute('aria-required')).toBe('true')
const { fileupload: customUpload } = await createFileUploadTest({
required: true,
uploadStrategy: 'custom',
})
const customInput = customUpload.querySelector<HTMLInputElement>('input[type="file"]')
expect(customInput?.required).toBe(false)
expect(customInput?.getAttribute('aria-required')).toBe('true')
})
test('custom strategy blocks form submit when required and no files are selected', async () => {
const { fileupload } = await createFileUploadTest({
required: true,
uploadStrategy: 'custom',
name: 'attachments',
})
const form = document.createElement('form')
document.body.appendChild(form)
form.appendChild(fileupload)
await fileupload.updateComplete
const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
form.dispatchEvent(submitEvent)
await fileupload.updateComplete
expect(submitEvent.defaultPrevented).toBe(true)
expect(fileupload.querySelector('pkt-alert')?.textContent).toContain('Du må laste opp minst én fil.')
})
test('custom strategy allows submit when required and at least one file exists', async () => {
const { fileupload } = await createFileUploadTest({
required: true,
uploadStrategy: 'custom',
multiple: true,
})
const form = document.createElement('form')
document.body.appendChild(form)
form.appendChild(fileupload)
await fileupload.updateComplete
await selectFiles(fileupload, [new File(['a'], 'ok.pdf', { type: 'application/pdf' })])
const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
form.dispatchEvent(submitEvent)
await fileupload.updateComplete
expect(submitEvent.defaultPrevented).toBe(false)
expect(fileupload.querySelector('pkt-alert')?.textContent || '').not.toContain(
'Du må laste opp minst én fil.',
)
})
test('rename operation updates target filename and emits update event', async () => {
const { fileupload } = await createFileUploadTest({
value: [createSeedFileItem('rename-1', 'old-name.pdf')],
renameFilesEnabled: true,
})
const events = recordFilesChanged(fileupload)
const renameButton = fileupload.querySelector<HTMLButtonElement>(
'button[aria-label="Rediger filnavn"]',
)
expect(renameButton).toBeInTheDocument()
renameButton?.click()
await fileupload.updateComplete
const renameInput = fileupload.querySelector<HTMLInputElement>(
'.pkt-fileupload__queue-display__item__rename-input',
)
expect(renameInput).toBeInTheDocument()
if (renameInput) {
renameInput.value = 'new-name.pdf'
renameInput.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
}
await fileupload.updateComplete
expect(events.at(-1)?.reason).toBe('update')
expect(events.at(-1)?.files[0].attributes.targetFilename).toBe('new-name.pdf')
})
test('comment operation supports add and remove comment', async () => {
const { fileupload } = await createFileUploadTest({ addCommentsEnabled: true })
await selectFiles(fileupload, [new File(['seed'], 'comment.pdf', { type: 'application/pdf' })])
const addCommentButton = Array.from(
fileupload.querySelectorAll<HTMLButtonElement>('.pkt-fileupload__queue-display__item__operation'),
).find((button) => button.textContent?.includes('Legg til kommentar'))
expect(addCommentButton).toBeInTheDocument()
addCommentButton?.click()
await fileupload.updateComplete
const commentInput = fileupload.querySelector<HTMLTextAreaElement>(
'.pkt-fileupload__queue-display__item__comment-input',
)
expect(commentInput).toBeInTheDocument()
if (commentInput) {
commentInput.value = 'Dette er en kommentar'
}
const saveCommentButton = Array.from(fileupload.querySelectorAll<HTMLButtonElement>('.pkt-btn')).find(
(button) => button.textContent?.includes('Legg til kommentar'),
)
saveCommentButton?.click()
await fileupload.updateComplete
expect(
fileupload.querySelector('.pkt-fileupload__queue-display__item__comment__text')?.textContent,
).toContain('Dette er en kommentar')
const deleteCommentButton = fileupload.querySelector<HTMLButtonElement>(
'button[aria-label="Slett kommentar"]',
)
expect(deleteCommentButton).toBeInTheDocument()
deleteCommentButton?.click()
await fileupload.updateComplete
expect(fileupload.querySelector('.pkt-fileupload__queue-display__item__comment__text')).not.toBeInTheDocument()
})
test('rename input closes on escape for keyboard users', async () => {
const { fileupload } = await createFileUploadTest({
value: [createSeedFileItem('rename-2', 'escape.pdf')],
renameFilesEnabled: true,
})
const renameButton = fileupload.querySelector<HTMLButtonElement>(
'button[aria-label="Rediger filnavn"]',
)
renameButton?.click()
await fileupload.updateComplete
const renameInput = fileupload.querySelector<HTMLInputElement>(
'.pkt-fileupload__queue-display__item__rename-input',
)
expect(renameInput).toBeInTheDocument()
renameInput?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await fileupload.updateComplete
expect(
fileupload.querySelector('.pkt-fileupload__queue-display__item__rename-input'),
).not.toBeInTheDocument()
})
test('custom operation renders inline ui', async () => {
const inlineOperation: TQueueItemOperation = {
id: 'inline-custom',
title: 'Inline custom',
renderInlineUI: ({ close }) =>
html`<span class="custom-inline-ui">Inline UI</span>
<button type="button" @click=${close}>Lukk</button>`,
}
const { fileupload } = await createFileUploadTest({
value: [createSeedFileItem('custom-inline-1', 'custom.pdf')],
extraOperations: [inlineOperation],
})
const button = Array.from(
fileupload.querySelectorAll<HTMLButtonElement>('.pkt-fileupload__queue-display__item__operation'),
).find((candidate) => candidate.textContent?.includes('Inline custom'))
button?.click()
await fileupload.updateComplete
expect(fileupload.querySelector('.custom-inline-ui')).toBeInTheDocument()
})
test('custom operation renders extended ui', async () => {
const extendedOperation: TQueueItemOperation = {
id: 'extended-custom',
title: 'Extended custom',
renderExtendedUI: () => html`<div class="custom-extended-ui">Utvidet UI</div>`,
}
const { fileupload } = await createFileUploadTest({
value: [createSeedFileItem('custom-extended-1', 'custom.pdf')],
extraOperations: [extendedOperation],
})
const button = Array.from(
fileupload.querySelectorAll<HTMLButtonElement>('.pkt-fileupload__queue-display__item__operation'),
).find((candidate) => candidate.textContent?.includes('Extended custom'))
button?.click()
await fileupload.updateComplete
expect(fileupload.querySelector('.custom-extended-ui')).toBeInTheDocument()
})
test('custom operation onClick receives the queue context', async () => {
const seen: Array<{ fileId: string; isActive: boolean }> = []
const clickOperation: TQueueItemOperation = {
id: 'click-custom',
title: 'Klikk',
onClick: ({ file, isActive }) => seen.push({ fileId: file.fileId, isActive }),
}
const { fileupload } = await createFileUploadTest({
value: [createSeedFileItem('click-1', 'click.pdf')],
extraOperations: [clickOperation],
})
const button = Array.from(
fileupload.querySelectorAll<HTMLButtonElement>('.pkt-fileupload__queue-display__item__operation'),
).find((candidate) => candidate.textContent?.includes('Klikk'))
button?.click()
await fileupload.updateComplete
expect(seen).toEqual([{ fileId: 'click-1', isActive: false }])
})
test('renders transfer progress state with progress bar for filename and thumbnail', async () => {
const seed = createSeedFileItem('progress-1', 'progress.pdf')
const transfers: TFileTransfer[] = [
{ fileId: 'progress-1', progress: 0.42, showProgress: true },
]
const { fileupload: filenameUpload } = await createFileUploadTest({
value: [seed],
transfers,
itemRenderer: 'filename',
})
expect(
filenameUpload.querySelector('.pkt-fileupload__queue-display__item--in-progress'),
).toBeInTheDocument()
expect(
filenameUpload.querySelector('.pkt-fileupload__queue-display__item__progress'),
).toBeInTheDocument()
const { fileupload: thumbnailUpload } = await createFileUploadTest({
value: [seed],
transfers,
itemRenderer: 'thumbnail',
})
expect(
thumbnailUpload.querySelector('.pkt-fileupload__queue-display__item--in-progress'),
).toBeInTheDocument()
expect(
thumbnailUpload.querySelector('.pkt-fileupload__queue-display__item__progress'),
).toBeInTheDocument()
})
test('renders uploading text state when progress bar is hidden', async () => {
const seed = createSeedFileItem('uploading-text-1', 'uploading.pdf')
const { fileupload } = await createFileUploadTest({
value: [seed],
transfers: [{ fileId: 'uploading-text-1', progress: 0, showProgress: false }],
itemRenderer: 'filename',
})
expect(
fileupload.querySelector('.pkt-fileupload__queue-display__item__loading-text')?.textContent,
).toContain('Laster opp')
})
test('renders error transfer state with expected class and message', async () => {
const seed = createSeedFileItem('error-1', 'failed.pdf')
const { fileupload } = await createFileUploadTest({
value: [seed],
transfers: [
{
fileId: 'error-1',
progress: 'error',
errorMessage: 'Opplastingen feilet',
showProgress: true,
lastProgress: 0.9,
},
],
})
expect(
fileupload.querySelector('.pkt-fileupload__queue-display__item--error'),
).toBeInTheDocument()
expect(
fileupload.querySelector('.pkt-fileupload__queue-display__item__error-message')?.textContent,
).toContain('Opplastingen feilet')
})
test('cancel button removes queue item and emits transfer-cancelled event', async () => {
const { fileupload } = await createFileUploadTest({ multiple: true })
const transferCancelledEvents: Array<any> = []
fileupload.addEventListener('transfer-cancelled', (event) =>
transferCancelledEvents.push((event as CustomEvent).detail),
)
const filesChangedEvents = recordFilesChanged(fileupload)
await selectFiles(fileupload, [new File(['seed'], 'cancel.pdf', { type: 'application/pdf' })])
const addedFileId = lastFilesFromEvents(filesChangedEvents)[0].fileId
fileupload.transfers = [{ fileId: addedFileId, progress: 0.5, showProgress: true }]
await fileupload.updateComplete
const cancelButton = fileupload.querySelector<HTMLButtonElement>(
'button[aria-label="Avbryt opplasting"]',
)
expect(cancelButton).toBeInTheDocument()
cancelButton?.click()
await fileupload.updateComplete
expect(filesChangedEvents.at(-1)?.reason).toBe('remove')
expect(fileupload.querySelectorAll('.pkt-fileupload__queue-display__item')).toHaveLength(0)
expect(transferCancelledEvents).toHaveLength(1)
expect(transferCancelledEvents[0].fileId).toBe(addedFileId)
})
test('renders canceled and done transfer state class names', async () => {
const canceled = createSeedFileItem('cancelled-file', 'cancelled.pdf')
const done = createSeedFileItem('done-file', 'done.pdf')
const { fileupload } = await createFileUploadTest({
value: [canceled, done],
transfers: [
{ fileId: 'cancelled-file', progress: 'canceled' },
{ fileId: 'done-file', progress: 'done' },
],
itemRenderer: 'thumbnail',
})
expect(
fileupload.querySelector('.pkt-fileupload__queue-display__item--canceled'),
).toBeInTheDocument()
expect(
fileupload.querySelector('.pkt-fileupload__queue-display__item--done'),
).toBeInTheDocument()
})
test('preview modal opens only for done previewable images in thumbnail mode', async () => {
const image = createSeedFileItem('preview-image-1', 'preview.png', 'image/png')
const doc = createSeedFileItem('preview-doc-1', 'not-previewable.pdf', 'application/pdf')
const { fileupload } = await createFileUploadTest({
value: [image, doc],
itemRenderer: 'thumbnail',
enableImagePreview: true,
transfers: [
{ fileId: 'preview-image-1', progress: 'done' },
{ fileId: 'preview-doc-1', progress: 'done' },
],
})
const previewButtons = fileupload.querySelectorAll<HTMLButtonElement>(
'.pkt-fileupload__queue-display__item__thumbnail__image-wrapper',
)
expect(previewButtons).toHaveLength(2)
expect(previewButtons[0].disabled).toBe(false)
expect(previewButtons[1].disabled).toBe(true)
previewButtons[0].click()
await fileupload.updateComplete
const modal = fileupload.querySelector<HTMLElement & { open?: boolean }>('pkt-modal')
expect(modal).toBeInTheDocument()
expect(modal?.open).toBe(true)
})
test('preview modal supports arrow navigation and escape', async () => {
const imageOne = createSeedFileItem('preview-nav-1', 'first.png', 'image/png')
const imageTwo = createSeedFileItem('preview-nav-2', 'second.png', 'image/png')
const { fileupload } = await createFileUploadTest({
value: [imageOne, imageTwo],
itemRenderer: 'thumbnail',
enableImagePreview: true,
transfers: [
{ fileId: 'preview-nav-1', progress: 'done' },
{ fileId: 'preview-nav-2', progress: 'done' },
],
})
const previewButton = fileupload.querySelector<HTMLButtonElement>(
'.pkt-fileupload__queue-display__item__thumbnail__image-wrapper',
)
previewButton?.click()
await fileupload.updateComplete
const modal = fileupload.querySelector<HTMLElement & { open?: boolean; headingText?: string }>(
'pkt-modal',
)
expect(modal?.headingText).toBe('first.png')
modal?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
await fileupload.updateComplete
expect(modal?.headingText).toBe('second.png')
modal?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await fileupload.updateComplete
expect(modal?.open).toBe(false)
})
test('preview modal traps tab focus inside modal controls', async () => {
const imageOne = createSeedFileItem('preview-trap-1', 'first.png', 'image/png')
const imageTwo = createSeedFileItem('preview-trap-2', 'second.png', 'image/png')
const { fileupload } = await createFileUploadTest({
value: [imageOne, imageTwo],
itemRenderer: 'thumbnail',
enableImagePreview: true,
transfers: [
{ fileId: 'preview-trap-1', progress: 'done' },
{ fileId: 'preview-trap-2', progress: 'done' },
],
})
const previewButton = fileupload.querySelector<HTMLButtonElement>(
'.pkt-fileupload__queue-display__item__thumbnail__image-wrapper',
)
previewButton?.click()
await fileupload.updateComplete
const closeButton = fileupload.querySelector<HTMLButtonElement>('pkt-modal .pkt-modal__closeButton .pkt-btn')
const nextButton = fileupload.querySelector<HTMLButtonElement>('.pkt-fileupload__image-preview__nav--next')
expect(closeButton).toBeInTheDocument()
expect(nextButton).toBeInTheDocument()
if (!closeButton || !nextButton) throw new Error('Expected preview modal controls to exist')
nextButton.focus()
nextButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
expect(document.activeElement).toBe(closeButton)
closeButton.focus()
closeButton.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
)
expect(document.activeElement).toBe(nextButton)
})
test('falls back to icon when thumbnail or preview image fails', async () => {
const image = createSeedFileItem('preview-error-1', 'broken.png', 'image/png')
const { fileupload } = await createFileUploadTest({
value: [image],
itemRenderer: 'thumbnail',
enableImagePreview: true,
transfers: [{ fileId: 'preview-error-1', progress: 'done' }],
})
const thumbnailImage = fileupload.querySelector<HTMLImageElement>(
'.pkt-fileupload__queue-display__item__thumbnail__image-wrapper img',
)
expect(thumbnailImage).toBeInTheDocument()
if (!thumbnailImage) throw new Error('Expected thumbnail image to exist')
thumbnailImage.dispatchEvent(new Event('error'))
await fileupload.updateComplete
expect(
fileupload.querySelector('.pkt-fileupload__queue-display__item__thumbnail .pkt-icon'),
).toBeInTheDocument()
const previewButton = fileupload.querySelector<HTMLButtonElement>(
'.pkt-fileupload__queue-display__item__thumbnail__image-wrapper',
)
previewButton?.click()
await fileupload.updateComplete
const previewImage = fileupload.querySelector<HTMLImageElement>('.pkt-fileupload__image-preview__image')
previewImage?.dispatchEvent(new Event('error'))
await fileupload.updateComplete
expect(
fileupload.querySelector('.pkt-fileupload__image-preview .pkt-icon'),
).toBeInTheDocument()
})
test('renders externally-supplied errorMessage in the alert and flags the input', async () => {
const { fileupload } = await createFileUploadTest({
hasError: true,
errorMessage: 'Opplastingen feilet hos tjenesten',
})
const alert = fileupload.querySelector('pkt-alert')
expect(alert?.textContent).toContain('Opplastingen feilet hos tjenesten')
const input = fileupload.querySelector<HTMLInputElement>('input[type="file"]')
expect(input?.getAttribute('aria-invalid')).toBe('true')
expect(input?.getAttribute('aria-describedby')).toContain(`${fileupload.id}-error`)
})
test('internal validation error still shown when no external error is set', async () => {
const { fileupload } = await createFileUploadTest({ allowedFormats: ['pdf'] })
await selectFiles(fileupload, [new File(['x'], 'bad.png', { type: 'image/png' })])
const alert = fileupload.querySelector('pkt-alert')
expect(alert?.textContent).toContain('Ugyldig filtype')
expect(
fileupload.querySelector<HTMLInputElement>('input[type="file"]')?.getAttribute('aria-invalid'),
).toBe('true')
})
test('forwards optionalTag and requiredTag to pkt-input-wrapper', async () => {
const { fileupload: optional } = await createFileUploadTest({
label: 'Vedlegg',
optionalTag: true,
})
const optionalWrapper = optional.querySelector<HTMLElement & { optionalTag?: boolean }>('pkt-input-wrapper')
expect(optionalWrapper?.optionalTag).toBe(true)
const { fileupload: required } = await createFileUploadTest({
label: 'Vedlegg',
requiredTag: true,
})
const requiredWrapper = required.querySelector<HTMLElement & { requiredTag?: boolean }>('pkt-input-wrapper')
expect(requiredWrapper?.requiredTag).toBe(true)
})
test('default id is unique per instance', async () => {
const { fileupload: first } = await createFileUploadTest()
const { fileupload: second } = await createFileUploadTest()
expect(first.id).toMatch(/^pkt-fileupload-/)
expect(second.id).toMatch(/^pkt-fileupload-/)
expect(first.id).not.toBe(second.id)
})
test('renders queue items in the order: in-progress → error → done/queued', async () => {
const value: FileItem[] = [
createSeedFileItem('done-1', 'done.pdf'),
createSeedFileItem('error-1', 'failed.pdf'),
createSeedFileItem('progress-1', 'uploading.pdf'),
createSeedFileItem('queued-1', 'queued.pdf'),
]
const transfers: TFileTransfer[] = [
{ fileId: 'done-1', progress: 'done' },
{ fileId: 'error-1', progress: 'error', errorMessage: 'failed' },
{ fileId: 'progress-1', progress: 0.5, showProgress: true },
{ fileId: 'queued-1', progress: 'queued' },
]
const { fileupload } = await createFileUploadTest({
uploadStrategy: 'custom',
multiple: true,
value,
transfers,
})
const titles = Array.from(
fileupload.querySelectorAll('.pkt-fileupload__queue-display__item__title'),
).map((el) => {
const head = el.querySelector('[data-pkt-truncate-part="first"]')?.textContent ?? ''
const tail = el.querySelector('[data-pkt-truncate-part="tail"]')?.textContent ?? ''
return (head + tail).trim()
})
expect(titles).toEqual(['uploading.pdf', 'failed.pdf', 'done.pdf', 'queued.pdf'])
})
test('truncateTail splits long filenames into head + tail spans and leaves short ones whole', async () => {
const longName = 'a-very-long-filename-that-needs-truncation.pdf'
const longSeed = {
fileId: 'truncate-long',
file: new File(['x'], longName, { type: 'application/pdf' }),
attributes: { targetFilename: longName },
}
const shortSeed = {
fileId: 'truncate-short',
file: new File(['x'], 'short.pdf', { type: 'application/pdf' }),
attributes: { targetFilename: 'short.pdf' },
}
const { fileupload } = await createFileUploadTest({
value: [longSeed, shortSeed],
truncateTail: 6,
})
const titles = fileupload.querySelectorAll('.pkt-fileupload__queue-display__item__title')
const longTitle = titles[0] as HTMLElement
const head = longTitle.querySelector('.pkt-fileupload__queue-display__item__title__head')
const tail = longTitle.querySelector('.pkt-fileupload__queue-display__item__title__tail')
expect(head?.textContent).toBe(longName.slice(0, longName.length - 6))
expect(tail?.textContent).toBe(longName.slice(-6))
// head + tail reconstruct the full filename (screen readers read both)
expect((head?.textContent ?? '') + (tail?.textContent ?? '')).toBe(longName)
const shortTitle = titles[1] as HTMLElement
expect(shortTitle.querySelector('.pkt-fileupload__queue-display__item__title__head')).toBeNull()
expect(
shortTitle.querySelector('[data-pkt-truncate-part="first"]')?.textContent?.trim(),
).toBe('short.pdf')
})
})