UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

950 lines (810 loc) 37.2 kB
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') }) })