import classNames from 'classnames' import { ChangeEvent, DragEvent, forwardRef, InputHTMLAttributes, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react' import { PktIcon } from '..' import { uiMultipleTexts, uiSingleTexts, uiThumbnailMultipleTexts, uiThumbnailSingleTexts } from './texts' import { FileItem, TFileItemList, TUploadStrategy } from './types' const DEFAULT_FORMATS_HELP_TEXT = '.PDF, .JPEG, .JPG, .PNG, .HEIC, .DOC, .DOCX, .ODT' /** * Props for the internal `DropZone` building block. * * This component is responsible for: * - rendering the native `` (visually hidden, still accessible) * - handling drag & drop + file dialog selection * - optionally rendering hidden inputs with file IDs when `uploadStrategy="custom"` */ interface IDropZoneProps extends Omit, 'checked' | 'size' | 'type' | 'value' | 'onChange' | 'width'> { /** Called with `FileItem`s created from dropped/selected files. */ onFilesAdded?: (files: TFileItemList) => void /** Used for the native input `id` and related label/ARIA wiring. */ id: string /** Allow selecting/dropping multiple files. */ multiple?: boolean /** Current file list (used for custom hidden inputs + re-populating the native input). */ value: TFileItemList /** * Field name. * - `uploadStrategy="form"`: used as `` so the files are posted on submit. * - `uploadStrategy="custom"`: used for hidden `` file IDs. */ name: string /** Upload mode. */ uploadStrategy: TUploadStrategy /** Enables alternate texts/UX for thumbnail view. */ isThumbnailView?: boolean /** Disables all interaction. */ disabled?: boolean /** IDs for screen reader announcements (uploaded/errors). */ srAnnouncementIds?: { uploaded: string errors: string } /** Whether the file input already has an external visible label associated with it. */ hasVisibleLabel?: boolean } export const DropZone = forwardRef( ( { id, multiple, value, onFilesAdded = () => {}, name, uploadStrategy, accept, isThumbnailView = false, disabled = false, srAnnouncementIds, hasVisibleLabel = false, ...inputProps }: IDropZoneProps, forwardedRef, ) => { const fileInputRef = useRef(null) const resolvedAccept = typeof accept === 'string' && accept.trim().length > 0 ? accept : undefined const acceptedFormatsReadableString = useMemo( () => resolvedAccept ? resolvedAccept .split(/\s*,\s*/) .map((format) => format.trim()) .filter(Boolean) .join(', ') .toUpperCase() : DEFAULT_FORMATS_HELP_TEXT, [resolvedAccept], ) useImperativeHandle(forwardedRef, () => fileInputRef.current! as HTMLInputElement) const [isDragActive, setIsDragActive] = useState(false) const uiTexts: typeof uiMultipleTexts = useMemo(() => { if (isThumbnailView) { return multiple ? uiThumbnailMultipleTexts : uiThumbnailSingleTexts } return multiple ? uiMultipleTexts : uiSingleTexts }, [multiple, isThumbnailView]) const populateNativeFileInput = useCallback( (fileItemList: TFileItemList) => { if (!fileInputRef.current) return try { fileInputRef.current.files = createFileList(fileItemList) } catch { // Setting files property may fail in test environments like jsdom // This is not critical for the component's functionality } }, [fileInputRef.current, value], ) useEffect(() => { populateNativeFileInput(value) }, [fileInputRef, value]) const filesAdded = useCallback( (files: Array) => { const addedFileItems = files.map((file) => new FileItem(file)) onFilesAdded(addedFileItems) // Clear the input value to allow uploading the same file again if needed if (fileInputRef.current) { fileInputRef.current.value = '' } }, [onFilesAdded], ) const filesSelectedInDialog = (event: ChangeEvent) => { const selectedFiles = (event.target as HTMLInputElement).files! const userCancelledFileSelectionDialog = selectedFiles.length === 0 if (userCancelledFileSelectionDialog) { if (multiple) { populateNativeFileInput(value) } else { // onFilesChanged([]) // TODO: Nullstill ved avbryt i enkel opplasting? } return } filesAdded(Array.from(selectedFiles)) } const filesDroppedOnDropzone = (files: Array) => { filesAdded(files) } const handleDrop = (event: DragEvent) => { event.preventDefault() setIsDragActive(false) if (disabled) return const droppedFiles = event.dataTransfer.files const arr: Array = Array.from(droppedFiles) filesDroppedOnDropzone(arr) } const handleDragOver = (event: DragEvent) => { event.preventDefault() if (disabled) return setIsDragActive(true) } const handleDragLeave = () => { setIsDragActive(false) } const handleDropZoneClick = useCallback( (event: React.MouseEvent) => { if (disabled) return const target = event.target as HTMLElement // ignore clicks on the explicit open button (it opens the dialog itself) if (target.closest('.pkt-fileupload__drop-zone__placeholder__title__open-file-dialog')) return fileInputRef.current?.click() }, [disabled], ) return (
{uploadStrategy === 'custom' && ( <> {value?.map((fileItem) => ( ))} )}
) }, ) /** * Create a `FileList` from a list of `FileItem`s. * * Note: relies on `DataTransfer`, which may be missing or restricted in test environments (jsdom). */ export const createFileList = (fileItemList: TFileItemList) => { const dataTransfer = new DataTransfer() for (const fileItem of fileItemList) { dataTransfer.items.add(fileItem.file) } return dataTransfer.files }