import '@testing-library/jest-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { axe, toHaveNoViolations } from 'jest-axe'
import { useState } from 'react'
import { vi } from 'vitest'
import { PktFileUpload } from './FileUpload'
import { FileItem, TFileItemList } from './types'
// Polyfill DataTransfer for jsdom
if (!global.DataTransfer) {
class DataTransferPolyfill {
items: DataTransferItemList
// @ts-ignore
files: FileList
constructor() {
const files: File[] = []
const items: DataTransferItem[] = []
this.items = {
add: (file: File) => {
files.push(file)
items.push({
kind: 'file',
type: file.type,
getAsFile: () => file,
} as DataTransferItem)
},
length: items.length,
} as DataTransferItemList
Object.defineProperty(this, 'files', {
get: () => {
const fileList = {
length: files.length,
item: (index: number) => files[index],
[Symbol.iterator]: function* () {
yield* files
},
}
files.forEach((file, index) => {
Object.defineProperty(fileList, index, {
value: file,
enumerable: true,
})
})
return fileList as FileList
},
})
}
}
global.DataTransfer = DataTransferPolyfill as any
}
// Polyfill for ResizeObserver
if (!global.ResizeObserver) {
class ResizeObserverPolyfill {
observe() {}
unobserve() {}
disconnect() {}
}
global.ResizeObserver = ResizeObserverPolyfill as any
}
const NOOP = () => {}
const getVisibleFilenameNode = (filename: string) =>
screen.queryByText(
(content, element) => content === filename && element?.getAttribute('data-pkt-truncate-part') === 'first',
)
const expectVisibleFilename = (filename: string) => {
expect(getVisibleFilenameNode(filename)).toBeInTheDocument()
}
const makeFilesPropWritable = (fileInput: HTMLInputElement) => {
// Make the files property settable in jsdom
let filesValue: FileList | null = null
Object.defineProperty(fileInput, 'files', {
get: () => filesValue,
set: (value: FileList | null) => {
filesValue = value
},
configurable: true,
})
}
expect.extend(toHaveNoViolations)
const createMockFile = (name: string, type = 'text/plain'): File => {
return new File(['content'], name, { type })
}
function createFileItem(name: string, fileId?: string) {
return new FileItem(createMockFile(name), fileId)
}
describe('PktFileUpload', () => {
describe('Rendering', () => {
it('should render the drop zone with default placeholder text for multiple files', () => {
render()
expect(screen.getByText(/Dra filer hit for å laste dem opp eller/)).toBeInTheDocument()
expect(screen.getByText('velg filer')).toBeInTheDocument()
expect(screen.getByText(/Format: .PDF, .JPEG, .JPG, .PNG, .HEIC, .DOC, .DOCX, .ODT/)).toBeInTheDocument()
})
it('should render the drop zone with single file text when multiple is false', () => {
render()
expect(screen.getByText(/Dra en fil for å laste den opp eller/)).toBeInTheDocument()
expect(screen.getByText('velg en fil')).toBeInTheDocument()
})
it('should render the attachment icon', () => {
const { container } = render()
expect(container.querySelector('.pkt-fileupload__drop-zone__placeholder__icon')).toBeInTheDocument()
})
it('should render with initial value', () => {
const initialValue: TFileItemList = [createFileItem('test.pdf', '1')]
render()
expectVisibleFilename('test.pdf')
})
it('should render multiple files in queue display', () => {
const initialValue: TFileItemList = [
createFileItem('file1.pdf', '1'),
createFileItem('file2.docx', '2'),
createFileItem('file3.png', '3'),
]
render()
expectVisibleFilename('file1.pdf')
expectVisibleFilename('file2.docx')
expectVisibleFilename('file3.png')
})
})
describe('File selection via dialog', () => {
it('should trigger file input when clicking "velg filer" button', async () => {
const user = userEvent.setup()
render()
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(fileInput, 'click')
const button = screen.getByRole('button', { name: /velg filer/i })
await user.click(button)
expect(clickSpy).toHaveBeenCalled()
})
it('should trigger file input when clicking the dropzone', async () => {
const user = userEvent.setup()
const { container } = render()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(fileInput, 'click')
const dropZone = container.querySelector('.pkt-fileupload__drop-zone')!
await user.click(dropZone)
expect(clickSpy).toHaveBeenCalled()
})
it('should call onFilesChanged when files are selected via dialog', () => {
const onFilesChanged = vi.fn()
render()
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
const file1 = createMockFile('test1.pdf')
const file2 = createMockFile('test2.pdf')
fireEvent.change(fileInput, { target: { files: [file1, file2] } })
expect(onFilesChanged).toHaveBeenCalledTimes(1)
const calledWith = onFilesChanged.mock.calls[0][0]
expect(calledWith).toHaveLength(2)
expect(calledWith[0].file).toBe(file1)
expect(calledWith[1].file).toBe(file2)
})
it('should assign unique IDs to added files', () => {
const onFilesChanged = vi.fn()
render()
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
const file1 = createMockFile('test1.pdf')
const file2 = createMockFile('test2.pdf')
fireEvent.change(fileInput, { target: { files: [file1, file2] } })
const calledWith = onFilesChanged.mock.calls[0][0]
expect(calledWith[0].fileId).toBeTruthy()
expect(calledWith[1].fileId).toBeTruthy()
expect(calledWith[0].fileId).not.toBe(calledWith[1].fileId)
})
it('should only keep one file when multiple is false', () => {
const onFilesChanged = vi.fn()
render()
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
const file1 = createMockFile('test1.pdf')
const file2 = createMockFile('test2.pdf')
fireEvent.change(fileInput, { target: { files: [file1, file2] } })
const calledWith = onFilesChanged.mock.calls[0][0]
expect(calledWith).toHaveLength(1)
expect(calledWith[0].file).toBe(file1)
})
it('should clear file input value after files are selected', () => {
const onFilesChanged = vi.fn()
render()
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
const file = createMockFile('test.pdf')
fireEvent.change(fileInput, { target: { files: [file] } })
expect(fileInput.value).toBe('')
})
})
describe('Drag and drop functionality', () => {
it('should show active state when dragging over drop zone', () => {
const { container } = render()
const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
fireEvent.dragOver(dropZone)
expect(dropZone).toHaveClass('pkt-fileupload__drop-zone--drag-active')
expect(screen.getByText(/Slipp filene her/)).toBeInTheDocument()
})
it('should show active state text for single file mode when dragging', () => {
const { container } = render()
const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
fireEvent.dragOver(dropZone)
expect(screen.getByText(/Slipp filen her/)).toBeInTheDocument()
})
it('should remove active state when drag leaves', () => {
const { container } = render()
const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
fireEvent.dragOver(dropZone)
expect(dropZone).toHaveClass('pkt-fileupload__drop-zone--drag-active')
fireEvent.dragLeave(dropZone)
expect(dropZone).not.toHaveClass('pkt-fileupload__drop-zone--drag-active')
})
it('should call onFilesChanged when files are dropped', () => {
const onFilesChanged = vi.fn()
const { container } = render()
const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
const file1 = createMockFile('dropped1.pdf')
const file2 = createMockFile('dropped2.pdf')
fireEvent.drop(dropZone, {
dataTransfer: {
files: [file1, file2],
},
})
expect(onFilesChanged).toHaveBeenCalledTimes(1)
const calledWith = onFilesChanged.mock.calls[0][0]
expect(calledWith).toHaveLength(2)
expect(calledWith[0].file).toBe(file1)
expect(calledWith[1].file).toBe(file2)
})
it('should remove active state after dropping files', () => {
const { container } = render()
const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
const file = createMockFile('dropped.pdf')
fireEvent.dragOver(dropZone)
expect(dropZone).toHaveClass('pkt-fileupload__drop-zone--drag-active')
fireEvent.drop(dropZone, {
dataTransfer: {
files: [file],
},
})
expect(dropZone).not.toHaveClass('pkt-fileupload__drop-zone--drag-active')
})
it('should only keep one file when dropping in single file mode', () => {
const onFilesChanged = vi.fn()
const { container } = render()
const dropZone = container.querySelector('.pkt-fileupload__drop-zone') as HTMLElement
const file1 = createMockFile('dropped1.pdf')
const file2 = createMockFile('dropped2.pdf')
fireEvent.drop(dropZone, {
dataTransfer: {
files: [file1, file2],
},
})
const calledWith = onFilesChanged.mock.calls[0][0]
expect(calledWith).toHaveLength(1)
expect(calledWith[0].file).toBe(file1)
})
})
describe('Multiple file mode', () => {
it('should append new files to existing files when multiple is true', () => {
const onFilesChanged = vi.fn()
const initialValue: TFileItemList = [createFileItem('existing.pdf', '1')]
render()
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
const newFile = createMockFile('new.pdf')
fireEvent.change(fileInput, { target: { files: [newFile] } })
const calledWith = onFilesChanged.mock.calls[0][0]
expect(calledWith).toHaveLength(2)
expect(calledWith[0].file.name).toBe('existing.pdf')
expect(calledWith[1].file.name).toBe('new.pdf')
})
it('should set file input multiple attribute when multiple is true', () => {
render()
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
expect(fileInput).toHaveAttribute('multiple')
})
it('should not set file input multiple attribute when multiple is false', () => {
render()
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
expect(fileInput).not.toHaveAttribute('multiple')
})
})
describe('Controlled component behavior', () => {
it('should update display when value prop changes', () => {
const initialValue: TFileItemList = [createFileItem('file1.pdf', '1')]
const { rerender } = render()
expectVisibleFilename('file1.pdf')
const updatedValue: TFileItemList = [createFileItem('file2.pdf', '2')]
rerender()
expect(getVisibleFilenameNode('file1.pdf')).not.toBeInTheDocument()
expectVisibleFilename('file2.pdf')
})
it('should handle empty value prop', () => {
render()
expect(screen.queryByText(/.pdf/)).not.toBeInTheDocument()
})
it('should work as uncontrolled component without value prop', () => {
const onFilesChanged = vi.fn()
render()
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
const file = createMockFile('test.pdf')
fireEvent.change(fileInput, { target: { files: [file] } })
expect(onFilesChanged).toHaveBeenCalledTimes(1)
})
})
describe('File removal', () => {
it('should render remove buttons for each file', () => {
const initialValue: TFileItemList = [createFileItem('file1.pdf', '1'), createFileItem('file2.pdf', '2')]
render()
const removeButtons = screen.getAllByRole('button', { name: /Slett/ })
expect(removeButtons).toHaveLength(2)
})
it.skip('should show close-circle icon on remove buttons', () => {
const initialValue: TFileItemList = [createFileItem('file1.pdf', '1')]
const { container } = render()
const closeIcon = container.querySelector('pkt-icon[name="close-circle"]')
expect(closeIcon).toBeInTheDocument()
})
})
describe('Accessibility', () => {
it('keeps label and input associations isolated across multiple instances without explicit ids', () => {
render(
<>
>,
)
const firstInput = screen.getByLabelText('Første vedlegg') as HTMLInputElement
const secondInput = screen.getByLabelText('Andre vedlegg') as HTMLInputElement
expect(firstInput.id).toBeTruthy()
expect(secondInput.id).toBeTruthy()
expect(firstInput.id).not.toBe(secondInput.id)
expect(firstInput).toHaveAttribute('aria-describedby', `${firstInput.id}-helptext`)
expect(secondInput).toHaveAttribute('aria-describedby', `${secondInput.id}-helptext`)
expect(document.getElementById(`${firstInput.id}-helptext`)).toHaveTextContent('Hjelpetekst A')
expect(document.getElementById(`${secondInput.id}-helptext`)).toHaveTextContent('Hjelpetekst B')
})
it('associates the label with the native file input and wires help text + error message ids', () => {
render(
'Ugyldig fil.'}
/>,
)
const fileInput = screen.getByLabelText('Last opp vedlegg') as HTMLInputElement
const file = createMockFile('test.pdf')
fireEvent.change(fileInput, { target: { files: [file] } })
expect(fileInput).toHaveAttribute('id', 'test-upload')
expect(fileInput).toHaveAttribute('aria-invalid', 'true')
expect(fileInput).toHaveAttribute('aria-describedby', 'test-upload-helptext test-upload-error')
expect(screen.getByText('Ugyldig fil.')).toBeInTheDocument()
})
it('applies native required only for form strategy', () => {
const { rerender } = render(
,
)
let fileInput = screen.getByLabelText('Vedlegg') as HTMLInputElement
expect(fileInput).toHaveAttribute('required')
expect(fileInput).toHaveAttribute('aria-required', 'true')
rerender(
,
)
fileInput = screen.getByLabelText('Vedlegg') as HTMLInputElement
expect(fileInput).not.toHaveAttribute('required')
expect(fileInput).toHaveAttribute('aria-required', 'true')
})
it('should have no accessibility violations', async () => {
const { container } = render()
// Note: The hidden file input doesn't have a label, but it's intentionally hidden
// and the visible UI provides the accessible interaction
const results = await axe(container, {
rules: {
label: { enabled: false },
},
})
expect(results).toHaveNoViolations()
})
it('should have no accessibility violations with files in queue', async () => {
const initialValue: TFileItemList = [createFileItem('file1.pdf', '1'), createFileItem('file2.pdf', '2')]
const { container } = render(
,
)
// Note: The hidden file input doesn't have a label, but it's intentionally hidden
// and the visible UI provides the accessible interaction
const results = await axe(container, {
rules: {
label: { enabled: false },
},
})
expect(results).toHaveNoViolations()
})
})
describe('Form submission values', () => {
it('blocks custom strategy submit when required and no files are selected', () => {
const { container } = render(
,
)
const form = container.querySelector('form') as HTMLFormElement
let submitWasPrevented = false
form.addEventListener('submit', (event) => {
submitWasPrevented = event.defaultPrevented
})
fireEvent.submit(form)
const fileInput = screen.getByLabelText('Last opp vedlegg') as HTMLInputElement
expect(submitWasPrevented).toBe(true)
expect(fileInput).toHaveAttribute('aria-invalid', 'true')
expect(fileInput).toHaveAttribute('aria-describedby', 'custom-required-upload-error')
expect(screen.getByText('Velg minst én fil før du sender inn skjemaet.')).toBeInTheDocument()
})
it('should populate file input with selected files for form submission', () => {
const TestFormComponent = () => {
const [files, setFiles] = useState([])
return (
)
}
const { container } = render()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
makeFilesPropWritable(fileInput)
const file1 = createMockFile('file1.pdf')
const file2 = createMockFile('file2.pdf')
// Simulate file selection
fireEvent.change(fileInput, { target: { files: [file1, file2] } })
// Verify that the file input contains the correct files that will be submitted
expect(fileInput.files).not.toBeNull()
expect(fileInput.files!.length).toBe(2)
expect(fileInput.files![0].name).toBe('file1.pdf')
expect(fileInput.files![1].name).toBe('file2.pdf')
expect(fileInput).toHaveAttribute('name', 'pktFileUpload')
})
it('should populate file input with single file for form submission', () => {
const TestFormComponent = () => {
const [files, setFiles] = useState([])
return (
)
}
const { container } = render()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
makeFilesPropWritable(fileInput)
const file = createMockFile('single-file.pdf')
// Simulate file selection
fireEvent.change(fileInput, { target: { files: [file] } })
// Verify that the file input contains the correct file that will be submitted
expect(fileInput.files).not.toBeNull()
expect(fileInput.files!.length).toBe(1)
expect(fileInput.files![0].name).toBe('single-file.pdf')
expect(fileInput).toHaveAttribute('name', 'pktFileUpload')
})
it('should include file IDs in form FormData with custom upload strategy', () => {
const initialValue: TFileItemList = [
createFileItem('file1.pdf', 'custom-id-1'),
createFileItem('file2.pdf', 'custom-id-2'),
]
const { container } = render(
,
)
const form = container.querySelector('form') as HTMLFormElement
const formData = new FormData(form)
const fileIds = formData.getAll('pktFileUpload')
expect(fileIds).toHaveLength(2)
expect(fileIds[0]).toBe('custom-id-1')
expect(fileIds[1]).toBe('custom-id-2')
})
it('should call onFileUploadRequested when files are selected with custom upload strategy', () => {
const onFileUploadRequested = vi.fn()
const TestFormComponent = () => {
const [files, setFiles] = useState([])
return (
)
}
const { container } = render()
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const file1 = createMockFile('upload-test.pdf')
const file2 = createMockFile('upload-test2.pdf')
// Simulate file selection
fireEvent.change(fileInput, { target: { files: [file1, file2] } })
// Verify that onFileUploadRequested was called for each file
expect(onFileUploadRequested).toHaveBeenCalledTimes(2)
// Check that it was called with FileItem objects containing the correct files
const firstCall = onFileUploadRequested.mock.calls[0][0]
const secondCall = onFileUploadRequested.mock.calls[1][0]
expect(firstCall.file).toBe(file1)
expect(firstCall.fileId).toBeTruthy()
expect(secondCall.file).toBe(file2)
expect(secondCall.fileId).toBeTruthy()
})
})
})