/* Prinsipper: - Fileupload-komponenten er stateless. All state må håndteres utenfor. - Dette er en controlled component, så når filer legges til, fjernes eller oppdateres, blir den nye fil-lista sendt ut via onFilesChanged. Komponenten over må dermed oppdatere value-prop'en. - Når brukeren velger en fil for opplasting, gir komponenten fila en unik ID. - `value`-typen til denne komponenten er en FileItemList - Støtte for to opplastingsstrategier: - 'form': Filene legges til i et skjema og lastes opp når skjemaet sendes inn. - 'custom': Når en fil legges til, kalles en callback-funksjon for å håndtere opplastingen manuelt. Når skjemaet sendes inn, postes bare fil-IDene. Server-applikasjonen kan dermed knytte de allerede opplastede filene til fil-IDene. - Støtte for flere filvalg (multiple) og enkel filvalg. - Støtte for tilleggsfunksjoner som å legge til kommentarer og endre filnavn via utvidelser. - Støtte for tilpassede renderer-komponenter for visning av filer i køen. */ import classNames from 'classnames' import { FC, forwardRef, HTMLAttributes, InputHTMLAttributes, ReactNode, useCallback, useEffect, useId, useImperativeHandle, useMemo, useRef, useState, } from 'react' import { PktAlert } from '../alert/Alert' import { PktInputWrapper } from '../inputwrapper/InputWrapper' import { DropZone } from './DropZone' import { addCommentOperation } from './extensions/Comments' import { removeFileOperation } from './extensions/Remove' import { renameFileOperation } from './extensions/Rename' import { useFileAttributes } from './hooks' import { ItemRenderers, QueueDisplay } from './QueueDisplay' import { TFileId, FileItem, TFileItemList, TFileTransfer, TItemRenderer, PktFileUploadContext, TQueueItemExtension, TUploadStrategy, } from './types' import { formatFileSize, parseFileSize } from './utils' // Re-export for external use export { formatFileSize, parseFileSize } from './utils' /** * Shared props for `PktFileUpload` (both `form` and `custom` strategies). * * The component can be used as: * - **controlled**: pass `value` + `onFilesChanged` * - **uncontrolled**: pass `defaultValue` (internal state is not stored; you still receive new lists via callbacks) */ interface IBaseFileUploadProps extends Omit< InputHTMLAttributes, 'checked' | 'size' | 'type' | 'value' | 'onChange' | 'width' | 'defaultValue' > { /** Called with the next file list when files are added/removed/updated. */ onFilesChanged?: (files: TFileItemList) => void /** Allow selecting/dropping multiple files. */ multiple?: boolean /** Controlled value (recommended). */ value?: TFileItemList /** Upload mode. Defaults to `"form"`. */ uploadStrategy?: TUploadStrategy /** Field name. Used for native input (`form`) or hidden ID inputs (`custom`). */ name: string /** Enable comment operation (disabled automatically in thumbnail view). */ addCommentsEnabled?: boolean /** Enable rename operation (disabled automatically in thumbnail view). */ renameFilesEnabled?: boolean /** Renderer for queue items (`filename`, `thumbnail`, or custom function). */ itemRenderer?: keyof typeof ItemRenderers | TItemRenderer /** Number of trailing characters to keep when truncating long filenames. */ truncateTail?: number /** * Called immediately when a file is added in `uploadStrategy="custom"`. * Use it to start uploading the file and update `transfers`. */ onFileUploadRequested?: (fileItem: FileItem) => void /** Uncontrolled initial value (use either `value` or `defaultValue`, not both). */ defaultValue?: TFileItemList /** Extra operations appended after built-ins (rename/comment). */ extraOperations?: Array /** Stretch to full container width (drop zone + queue). */ fullWidth?: boolean /** Allowed formats for built-in validation (extensions like `pdf`, or MIME patterns like `image/*`). */ allowedFormats?: string[] /** Custom message for invalid format. Use `{formats}` placeholder. */ formatErrorMessage?: string /** Max file size (e.g. `"5MB"` or bytes). */ maxFileSize?: string | number /** Custom message for size validation. Use `{maxSize}` placeholder. */ sizeErrorMessage?: string /** Optional additional validation hook (runs after built-in format/size checks). Return string to block. */ onFileValidation?: (file: File) => string | null /** External/programmatic error message shown under the component. */ errorMessage?: string /** External error flag (combined with internal validation errors). */ hasError?: boolean /** Disable the whole component (no interaction). */ disabled?: boolean /** Optional label/title (wraps the component in `PktInputWrapper`). */ label?: string /** Help text under the label. */ helptext?: string | ReactNode /** Show "Valgfritt" tag in wrapper. */ optionalTag?: boolean /** Show "Må fylles ut" tag in wrapper. */ requiredTag?: boolean /** Marks the upload as required. Native validation is used in `form`; submit validation in `custom`. */ required?: boolean /** Enable image preview modal (only applies to thumbnail renderer). */ enableImagePreview?: boolean } /** Props for `uploadStrategy="form"` (native file input submission). */ interface IFileInputFileUploadProps extends IBaseFileUploadProps, Omit, 'value' | 'onChange' | 'defaultValue'> { uploadStrategy?: 'form' onFileUploadRequested?: never } /** * Props for `uploadStrategy="custom"` (upload handled externally). * * Requirements: * - `transfers` must be provided (to render status/progress) * - `onFileUploadRequested` is called for each added file */ interface ImmediateFileUploadProps extends IBaseFileUploadProps { id: string uploadStrategy: 'custom' transfers: Array onFileUploadRequested: (fileItem: FileItem) => void onTransferCancelled?: (fileItemId: string) => void } export type IPktFileUpload = IFileInputFileUploadProps | ImmediateFileUploadProps export const PktFileUpload: FC = forwardRef( ( { value: valueProp, defaultValue, id: idProp, multiple = false, uploadStrategy = 'form', addCommentsEnabled = false, renameFilesEnabled = false, truncateTail, onFilesChanged, onFileUploadRequested, extraOperations = [], itemRenderer: itemRendererProp = ItemRenderers.filename, fullWidth = false, allowedFormats, formatErrorMessage, maxFileSize, // default to 5 MB sizeErrorMessage, onFileValidation, errorMessage: externalErrorMessage, hasError: externalHasError = false, disabled = false, label, helptext, optionalTag, requiredTag, required = false, enableImagePreview = false, ['aria-describedby']: ariaDescribedByProp, ['aria-invalid']: ariaInvalidProp, ...props }: IPktFileUpload, forwardedRef, ) => { const generatedId = useId() const id = idProp ?? `pkt-fileupload-${generatedId}` const fileInputRef = useRef(null) useImperativeHandle(forwardedRef, () => fileInputRef.current as HTMLInputElement, []) const transfers = 'transfers' in props ? props.transfers : undefined const [validationError, setValidationError] = useState(null) // Combine external error with internal validation error const hasError = externalHasError || !!validationError const errorMessage = externalErrorMessage || validationError const errorMessageId = errorMessage ? `${id}-error` : undefined const helptextId = helptext ? `${id}-helptext` : undefined const describedBy = [ariaDescribedByProp, helptextId, errorMessageId].filter(Boolean).join(' ') || undefined const isAriaInvalid = hasError || ariaInvalidProp === true || ariaInvalidProp === 'true' // Parse maxFileSize once (supports "5MB" strings or raw bytes) const maxFileSizeBytes = maxFileSize ? parseFileSize(maxFileSize) : undefined // Built-in validation function const validateFile = useCallback( (file: File): string | null => { // 1. Check format if allowedFormats is specified if (allowedFormats && allowedFormats.length > 0) { const fileExtension = file.name.split('.').pop()?.toLowerCase() || '' const fileMimeType = file.type.toLowerCase() const isAllowed = allowedFormats.some((format) => { const normalizedFormat = format.toLowerCase().replace(/^\./, '') // Remove leading dot if present // Check MIME type patterns (e.g. 'image/*', 'application/pdf') if (normalizedFormat.includes('/')) { if (normalizedFormat.endsWith('/*')) { const category = normalizedFormat.replace('/*', '') return fileMimeType.startsWith(category + '/') } return fileMimeType === normalizedFormat } // Check file extension (e.g. 'pdf', 'jpg', '.png') return fileExtension === normalizedFormat }) if (!isAllowed) { const formatsDisplay = allowedFormats.join(', ') const defaultMessage = `Ugyldig filtype. Tillatte formater: ${formatsDisplay}` return formatErrorMessage?.replace('{formats}', formatsDisplay) || defaultMessage } } // 2. Check file size if maxFileSize is specified if (maxFileSizeBytes && file.size > maxFileSizeBytes) { const maxSizeDisplay = formatFileSize(maxFileSizeBytes) const defaultMessage = `Filen er for stor. Maks størrelse er ${maxSizeDisplay}.` return sizeErrorMessage?.replace('{maxSize}', maxSizeDisplay) || defaultMessage } // 3. Run custom validation if provided if (onFileValidation) { return onFileValidation(file) } return null }, [allowedFormats, formatErrorMessage, maxFileSizeBytes, sizeErrorMessage, onFileValidation], ) if (valueProp !== undefined && defaultValue !== undefined) { // eslint-disable-next-line no-console console.warn( "PktFileupload: Både value og defaultValue er angitt. Komponenten kan være enten 'controlled' eller 'uncontrolled', ikke begge deler. Bruk kun én av dem. Se https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable", ) } if (valueProp !== undefined && onFilesChanged === undefined) { // eslint-disable-next-line no-console console.warn( "PktFileupload: value-prop er angitt uten onFilesChanged-callback. Når en komponent er 'controlled', må endringer håndteres via en callback. Vennligst legg til onFilesChanged-callback for å håndtere endringer i fil-listen. Se https://react.dev/reference/react-dom/components/input#controlling-an-input-with-a-state-variable", ) } const isControlled = valueProp !== undefined const value = isControlled ? valueProp : defaultValue || [] useEffect(() => { if (uploadStrategy !== 'custom' || !required) return const input = fileInputRef.current const form = input?.form if (!form) return const handleFormSubmit = (event: SubmitEvent) => { if (value.length > 0) return event.preventDefault() setValidationError('Velg minst én fil før du sender inn skjemaet.') } form.addEventListener('submit', handleFormSubmit) return () => { form.removeEventListener('submit', handleFormSubmit) } }, [required, uploadStrategy, value.length]) const itemRenderer = typeof itemRendererProp === 'string' ? ItemRenderers[itemRendererProp] : itemRendererProp const isThumbnailView = itemRendererProp === 'thumbnail' const explicitAccept = typeof props.accept === 'string' ? props.accept.trim() : undefined const acceptFromAllowedFormats = useMemo(() => { if (!allowedFormats || allowedFormats.length === 0) return undefined return allowedFormats .map((format) => format.trim()) .filter(Boolean) .map((format) => { const normalized = format.toLowerCase() if (normalized.includes('/')) return normalized if (normalized.startsWith('.')) return normalized return `.${normalized}` }) .join(', ') }, [allowedFormats]) const resolvedAcceptForDropZone = explicitAccept || acceptFromAllowedFormats const effectiveRenameEnabled = renameFilesEnabled && !isThumbnailView const effectiveCommentsEnabled = addCommentsEnabled && !isThumbnailView const onFilesAdded = useCallback( (addedFileItems: TFileItemList) => { // Run validation on each file for (const fileItem of addedFileItems) { const error = validateFile(fileItem.file) if (error) { setValidationError(error) return // Don't add any files if validation fails } } // Clear any previous validation error setValidationError(null) const newValue: TFileItemList = multiple ? [...value, ...addedFileItems] : [addedFileItems[0]] if (onFilesChanged) { onFilesChanged(newValue) } if (uploadStrategy === 'custom' && onFileUploadRequested) { addedFileItems.forEach((fileItem) => { onFileUploadRequested?.(fileItem) }) } }, [onFilesChanged, value, multiple, uploadStrategy, onFileUploadRequested, validateFile], ) const onFileUpdated = useCallback( (fileId: TFileId, updates: Partial) => { const newValue = value.map((fileItem) => (fileItem.fileId === fileId ? { ...fileItem, ...updates } : fileItem)) if (onFilesChanged) { onFilesChanged(newValue) } }, [onFilesChanged, value], ) const onFileRemoved = useCallback( (fileId: TFileId) => { const newValue = value.filter((fileItem) => fileItem.fileId !== fileId) if (onFilesChanged) { onFilesChanged(newValue) } if ('onTransferCancelled' in props && props.onTransferCancelled) { props.onTransferCancelled?.(fileId) } }, [onFilesChanged, value, props], ) const { fileAttributes } = useFileAttributes(value, onFileUpdated) const queueItemExtensions = useMemo( () => [ { op: renameFileOperation, enabled: effectiveRenameEnabled, }, { op: (attributes: Parameters[0]) => addCommentOperation(attributes), enabled: effectiveCommentsEnabled, }, ] .filter(({ enabled }) => enabled) .map(({ op }) => op) .concat(...extraOperations), [effectiveRenameEnabled, effectiveCommentsEnabled], ) // Screen reader announcement IDs const srAnnouncementIds = useMemo( () => ({ uploaded: `${id}-uploaded`, errors: `${id}-errors`, }), [id], ) // Compute counts for screen reader announcements. // F.eks. "1 av 3 filer lastet opp" eller "2 av 3 filer feilet" const { totalCount, uploadedCount, failedCount } = useMemo(() => { const totalCount = value.length if (!transfers) { return { totalCount, uploadedCount: 0, failedCount: 0 } } let uploadedCount = 0 let failedCount = 0 for (const fileItem of value) { const transfer = transfers.find((t) => t.fileId === fileItem.fileId) if (transfer?.progress === 'done') uploadedCount++ if (transfer?.progress === 'error') failedCount++ } return { totalCount, uploadedCount, failedCount } }, [value, transfers]) const totalLabel = totalCount === 1 ? 'fil' : 'filer' const fileUploadContent = (
{/* Screen reader announcements - visually hidden */}
{totalCount > 0 && uploadedCount > 0 && `${uploadedCount} av ${totalCount} ${totalLabel} lastet opp`}
{totalCount > 0 && failedCount > 0 && `Feil ved opplasting: ${failedCount} av ${totalCount} ${totalLabel} feilet`}
{hasError && errorMessage && ( {errorMessage} )} ext(fileAttributes)) .concat(removeFileOperation(onFileRemoved))} />
) // Wrap with InputWrapper if label is provided if (label) { return ( {fileUploadContent} ) } return fileUploadContent }, ) PktFileUpload.displayName = 'PktFileUpload'