diff --git a/component.manifest.json b/component.manifest.json index 1fc17fd3f..070c42f3b 100644 --- a/component.manifest.json +++ b/component.manifest.json @@ -601,7 +601,9 @@ "name": "TableOfContents", "category": "navigation", "sourcePath": "src/tedi/components/navigation/table-of-contents", - "status": [], + "status": [ + "breakpointSupport" + ], "description": "Navigational table of contents for long pages or multistep forms, composed from nestable TableOfContents.Item entries.", "keyProps": [ "activeId", diff --git a/skills/tedi-react/references/forms.md b/skills/tedi-react/references/forms.md index 8fed13d50..3b47b2066 100644 --- a/skills/tedi-react/references/forms.md +++ b/skills/tedi-react/references/forms.md @@ -409,14 +409,14 @@ Multiple helpers: ```tsx import { FileUpload, FileDropzone } from '@tedi-design-system/react/tedi'; -// Button-based upload (`maxSize` is in MB) +// Button-based upload (`maxSize` is in bytes — e.g. `5 * 1024 ** 2` for 5 MB) ``` **Restrictions hint** — both components auto-render an "allowed types / max size" hint below the field. Hide it with `showRestrictions={false}` when the same info lives elsewhere (e.g. a `tooltip`); rejection error messages still render either way: ```tsx - + ``` **Rejections are reported and observable** — a file failing `accept`/`maxSize` (dragged *or* picked) surfaces a localised message, and `onChange` fires even when the drop is fully rejected (with the unchanged list), so single-file rejections aren't silent. diff --git a/src/tedi/components/form/file-dropzone/file-dropzone.spec.tsx b/src/tedi/components/form/file-dropzone/file-dropzone.spec.tsx index 13d06ef92..a99932161 100644 --- a/src/tedi/components/form/file-dropzone/file-dropzone.spec.tsx +++ b/src/tedi/components/form/file-dropzone/file-dropzone.spec.tsx @@ -87,12 +87,12 @@ describe('FileDropzone', () => { fileInputRef: { current: null }, }); - render(); + render(); expect(screen.queryByText('Aggregate error')).not.toBeInTheDocument(); }); - it('still shows the aggregate error for single-file individual validation (rejected file is not listed)', () => { + it('suppresses the aggregate hook error for single-file keepRejectedFiles too (the kept file carries it)', () => { mockUseFileUpload.mockReturnValue({ innerFiles: [], errorHelper: { type: 'error', text: 'Aggregate error' }, @@ -103,9 +103,9 @@ describe('FileDropzone', () => { fileInputRef: { current: null }, }); - render(); + render(); - expect(screen.getByText('Aggregate error')).toBeInTheDocument(); + expect(screen.queryByText('Aggregate error')).not.toBeInTheDocument(); }); it('still renders a consumer-provided error helper under the dropzone with individual validation', () => { @@ -124,7 +124,7 @@ describe('FileDropzone', () => { id="3c" name="file" label="Upload File" - validateIndividually + keepRejectedFiles helper={{ type: 'error', text: 'Required' }} /> ); @@ -188,7 +188,7 @@ describe('FileDropzone', () => { fileInputRef: { current: null }, }); - render(); + render(); const useDropzoneMock = useDropzone as jest.Mock; const dropzoneProps = useDropzoneMock.mock.calls[0][0]; @@ -324,9 +324,9 @@ describe('FileDropzone', () => { name="docs" label="Label" accept=".pdf,.txt" - maxSize={100} + maxSize={100 * 1024 ** 2} multiple - validateIndividually + keepRejectedFiles files={[]} defaultFiles={[]} onChange={() => undefined} @@ -336,8 +336,8 @@ describe('FileDropzone', () => { ); const label = container.querySelector('label'); - ['accept', 'maxsize', 'files', 'name', 'defaultfiles', 'announcementtimeout', 'validateindividually'].forEach( - (attr) => expect(label).not.toHaveAttribute(attr) + ['accept', 'maxsize', 'files', 'name', 'defaultfiles', 'announcementtimeout', 'keeprejectedfiles'].forEach((attr) => + expect(label).not.toHaveAttribute(attr) ); expect(container.querySelector('input[type="file"]')).toHaveAttribute('name', 'docs'); }); diff --git a/src/tedi/components/form/file-dropzone/file-dropzone.stories.tsx b/src/tedi/components/form/file-dropzone/file-dropzone.stories.tsx index f4e3e1fef..4a25594f9 100644 --- a/src/tedi/components/form/file-dropzone/file-dropzone.stories.tsx +++ b/src/tedi/components/form/file-dropzone/file-dropzone.stories.tsx @@ -45,7 +45,7 @@ export const WithHint: Story = { args: { name: 'file', accept: '.jpg,.png,.pdf', - maxSize: 1, + maxSize: 1024 ** 2, helper: { text: 'JPG, PNG, PDF suurusega kuni 1 MB.', }, @@ -59,7 +59,7 @@ export const Multiple: Story = { name: 'file-multiple', multiple: true, accept: '.jpg,.png,.pdf', - maxSize: 1, + maxSize: 1024 ** 2, defaultFiles: [{ name: 'report.pdf' }, { name: 'report_1.pdf' }, { name: 'report_2.pdf' }], helper: { text: 'JPG, PNG, PDF suurusega kuni 1 MB.', @@ -75,7 +75,7 @@ export const BatchValidation: Story = { args: { id: 'file-dropzone-batch-validation', name: 'file-batch-validation', - maxSize: 1, + maxSize: 1024 ** 2, accept: '.pdf,.txt', multiple: true, defaultFiles: [{ id: '1', name: 'taotlus.pdf' }], @@ -96,17 +96,17 @@ export const BatchValidation: Story = { }; /** - * `validateIndividually`: each file is validated separately and kept with its own valid/invalid - * state, so the user can see and remove the ones that failed. + * `keepRejectedFiles` (needs `multiple`): files that fail validation stay in the list with their + * own invalid state instead of being discarded, so the user can see and remove the ones that failed. */ export const IndividualValidation: Story = { args: { id: 'file-dropzone-individual-validation', name: 'file-individual-validation', multiple: true, - maxSize: 1, + maxSize: 1024 ** 2, accept: '.pdf,.txt', - validateIndividually: true, + keepRejectedFiles: true, defaultFiles: [ { id: '1', name: 'taotlus_scan_lk_1.pdf' }, { id: '2', name: 'taotlus_scan_lk_2.pdf' }, @@ -143,7 +143,7 @@ export const WithAttachmentProps: Story = { id: 'file-dropzone-attachment-props', name: 'file-attachment-props', multiple: true, - maxSize: 200, + maxSize: 200 * 1024 ** 2, defaultFiles: [ { id: '1', name: 'arve_2026_06.pdf', size: 1_200_000 }, { id: '2', name: 'aastaaruanne_2025.pdf', size: 5_400_000 }, @@ -200,7 +200,7 @@ export const States: Story = { diff --git a/src/tedi/components/form/file-dropzone/file-dropzone.tsx b/src/tedi/components/form/file-dropzone/file-dropzone.tsx index 52a894b96..407c7f91d 100644 --- a/src/tedi/components/form/file-dropzone/file-dropzone.tsx +++ b/src/tedi/components/form/file-dropzone/file-dropzone.tsx @@ -96,7 +96,7 @@ export const FileDropzone = (props: FileDropzoneProps): JSX.Element => { accept, maxSize, multiple, - validateIndividually, + keepRejectedFiles, defaultFiles, files, onChange, @@ -108,7 +108,7 @@ export const FileDropzone = (props: FileDropzoneProps): JSX.Element => { accept, maxSize, multiple, - validateIndividually, + keepRejectedFiles, defaultFiles, files, onChange, @@ -124,7 +124,7 @@ export const FileDropzone = (props: FileDropzoneProps): JSX.Element => { disabled, accept: toDropzoneAccept(accept), multiple, - maxSize: maxSize ? maxSize * 1024 ** 2 : undefined, + maxSize, onDrop: (acceptedFiles, fileRejections = []) => { if (disabled) return; @@ -144,7 +144,7 @@ export const FileDropzone = (props: FileDropzoneProps): JSX.Element => { }, }); - const autoError = validateIndividually && multiple ? undefined : errorHelper; + const autoError = keepRejectedFiles ? undefined : errorHelper; const errorFeedback = autoError ?? (helper?.type === 'error' ? helper : undefined); const hintFeedback = helper && helper.type !== 'error' ? helper : restrictionsHint; diff --git a/src/tedi/components/form/file-upload/file-upload.spec.tsx b/src/tedi/components/form/file-upload/file-upload.spec.tsx index 595163052..921c48296 100644 --- a/src/tedi/components/form/file-upload/file-upload.spec.tsx +++ b/src/tedi/components/form/file-upload/file-upload.spec.tsx @@ -18,7 +18,7 @@ describe('FileUpload component', () => { onDelete: jest.fn(), accept: '.jpg,.png', multiple: true, - maxSize: 5, + maxSize: 5 * 1024 ** 2, label: 'Upload files', }; @@ -75,8 +75,8 @@ describe('FileUpload component', () => { render( ); @@ -110,7 +110,7 @@ describe('FileUpload component', () => { }); it('formats the max-size hint into readable units', () => { - render(); + render(); expect(screen.getByText(/file-upload.max-size 512 KB/i)).toBeInTheDocument(); }); @@ -205,7 +205,7 @@ describe('FileUpload component', () => { }); it('should return helper text when accept and maxSize are provided', () => { - const props = { ...defaultProps, accept: '.jpg,.png', maxSize: 5 }; + const props = { ...defaultProps, accept: '.jpg,.png', maxSize: 5 * 1024 ** 2 }; render(); expect(screen.getByText(/file-upload.accept .jpg, .png/i)).toBeInTheDocument(); expect(screen.getByText(/file-upload.max-size 5 MB/i)).toBeInTheDocument(); @@ -305,8 +305,8 @@ describe('FileUpload component', () => { expect(screen.getByText('test.jpg')).toBeInTheDocument(); }); - it('should add valid files individually when validateIndividually is true', () => { - render(); + it('should add valid files individually when keepRejectedFiles is true', () => { + render(); const input = screen.getByLabelText(/Upload files/i); const validFile = new File(['dummy content'], 'test.jpg', { type: 'image/jpeg' }); const invalidFile = new File(['dummy content'], 'test.txt', { type: 'text/plain' }); @@ -338,7 +338,7 @@ describe('FileUpload component', () => { }); it('should handle file size validation correctly', () => { - render(); + render(); const input = screen.getByLabelText(/Upload files/i); const largeFile = new File(['a'.repeat(6 * 1024 * 1024)], 'large.jpg', { diff --git a/src/tedi/components/form/file-upload/file-upload.stories.tsx b/src/tedi/components/form/file-upload/file-upload.stories.tsx index c256e4a3c..33cc84902 100644 --- a/src/tedi/components/form/file-upload/file-upload.stories.tsx +++ b/src/tedi/components/form/file-upload/file-upload.stories.tsx @@ -92,10 +92,10 @@ export const ValidationFailed: Story = { id: 'file-upload-validation-failed', name: 'file-validation-failed', label: 'Laadi fail üles', - maxSize: 0.001, + maxSize: 1024, accept: '.pdf,.txt', multiple: true, - validateIndividually: true, + keepRejectedFiles: true, defaultFiles: [{ name: 'taotlus_scan_lk_1.pdf', isValid: false }], }, render: (args) => ( @@ -114,10 +114,10 @@ export const ValidationSuccess: Story = { id: 'file-upload-validation-failed', name: 'file-validation-failed', label: 'Laadi fail üles', - maxSize: 0.001, + maxSize: 1024, accept: '.pdf,.txt', multiple: true, - validateIndividually: true, + keepRejectedFiles: true, defaultFiles: [{ name: 'taotlus_scan_lk_1.pdf', isValid: false }], }, render: (args) => ( @@ -137,9 +137,9 @@ export const MultipleWithIndividualValidation: Story = { name: 'file-multiple-individual-validation', label: 'Laadi failid üles', multiple: true, - maxSize: 0.01, + maxSize: 10 * 1024, accept: '.pdf,.txt', - validateIndividually: true, + keepRejectedFiles: true, hasClearButton: true, defaultFiles: [ { name: 'taotlus_scan_lk_1.pdf' }, @@ -228,7 +228,7 @@ export const SizeLimited: Story = { id: 'file-upload-size-limited', name: 'file-size-limited', label: 'Laadi fail üles', - maxSize: 0.001, + maxSize: 1024, multiple: true, }, }; @@ -238,7 +238,7 @@ export const ExtensionAndSizeLimit: Story = { id: 'file-upload-size-extension-limited', name: 'file-size-extension-limited', label: 'Laadi fail üles', - maxSize: 0.001, + maxSize: 1024, accept: '.pdf,.txt', multiple: true, }, diff --git a/src/tedi/components/form/file-upload/file-upload.tsx b/src/tedi/components/form/file-upload/file-upload.tsx index b78b8dd6d..bfbae7eba 100644 --- a/src/tedi/components/form/file-upload/file-upload.tsx +++ b/src/tedi/components/form/file-upload/file-upload.tsx @@ -86,13 +86,16 @@ export interface FileUploadProps extends Omit { */ disabled?: boolean; /** - * Maximum allowed file size in megabytes (MB). + * Maximum allowed file size in bytes (e.g. `5 * 1024 ** 2` for 5 MB). */ maxSize?: number; /** - * If `true`, validates each file separately instead of rejecting all at once. + * When `true`, files that fail validation stay in the list marked invalid instead of being + * discarded, so the failing file is shown; when `false`, only valid files are added. Applies to + * both single and multiple uploads. + * @default false */ - validateIndividually?: boolean; + keepRejectedFiles?: boolean; /** * Whether to show the auto-generated restrictions hint (allowed types / max size) * below the field. Turn it off when the same info is shown elsewhere to avoid a @@ -124,7 +127,7 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => { readOnly, disabled = false, maxSize, - validateIndividually = false, + keepRejectedFiles = false, showRestrictions, size = 'default', helper, @@ -135,7 +138,7 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => { accept, maxSize, multiple, - validateIndividually, + keepRejectedFiles, defaultFiles, onChange, onDelete, diff --git a/src/tedi/helpers/hooks/use-file-upload.spec.tsx b/src/tedi/helpers/hooks/use-file-upload.spec.tsx index a8f0371ce..4cac41bfb 100644 --- a/src/tedi/helpers/hooks/use-file-upload.spec.tsx +++ b/src/tedi/helpers/hooks/use-file-upload.spec.tsx @@ -41,6 +41,17 @@ describe('useFileUpload — accept matching', () => { expect(onChange).toHaveBeenLastCalledWith([]); }); + + it('keeps a rejected single file (marked invalid) when keepRejectedFiles is set, even without multiple', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => useFileUpload({ accept: 'image/*', keepRejectedFiles: true, onChange })); + + act(() => result.current.onFileChange(changeEvent([makeFile('doc.pdf', 'application/pdf')]))); + + expect(onChange).toHaveBeenLastCalledWith([expect.objectContaining({ name: 'doc.pdf', isValid: false })]); + expect(result.current.innerFiles).toHaveLength(1); + expect(result.current.errorHelper).toBeDefined(); + }); }); describe('useFileUpload — onFileRemove error clearing', () => { diff --git a/src/tedi/helpers/hooks/use-file-upload.ts b/src/tedi/helpers/hooks/use-file-upload.ts index 7c12b69f9..259911e00 100644 --- a/src/tedi/helpers/hooks/use-file-upload.ts +++ b/src/tedi/helpers/hooks/use-file-upload.ts @@ -37,7 +37,7 @@ export interface UseFileUploadProps { */ accept?: string; /** - * The maximum file size allowed for upload, in megabytes (MB). + * The maximum file size allowed for upload, in bytes (e.g. `5 * 1024 ** 2` for 5 MB). */ maxSize?: number; /** @@ -46,9 +46,13 @@ export interface UseFileUploadProps { */ multiple?: boolean; /** - * If true, each file is validated separately instead of rejecting all at once. + * When `true`, files that fail validation (wrong type or over `maxSize`) stay in the list marked + * invalid instead of being discarded, so the failing file is shown under the dropzone; when + * `false`, only valid files are added. Applies to both single and multiple uploads; files are + * validated individually either way. + * @default false */ - validateIndividually?: boolean; + keepRejectedFiles?: boolean; /** * An array of default files that are preloaded in the upload list. */ @@ -85,7 +89,7 @@ export interface UseFileUploadProps { /** * Formats a byte count into a readable size (`512 KB`, `2.5 MB`, …). Uses 1024-based - * units to stay consistent with the size validation (`maxSize * 1024 ** 2`), and rolls + * units to stay consistent with the byte-based `maxSize` validation, and rolls * sub-1-unit values down so `0.5 MB` reads as `512 KB` rather than `0.5MB` (#888). */ export const formatFileSize = (bytes: number): string => { @@ -111,7 +115,7 @@ const getDefaultHelpers = ( const text = [ accept && `${getLabel('file-upload.accept')} ${accept.replaceAll(',', ', ')}`, - maxSize && `${getLabel('file-upload.max-size')} ${formatFileSize(maxSize * 1024 ** 2)}`, + maxSize && `${getLabel('file-upload.max-size')} ${formatFileSize(maxSize)}`, ] .filter(Boolean) .join('. '); @@ -133,7 +137,7 @@ export const useFileUpload = (props: UseFileUploadProps) => { accept, maxSize, multiple = false, - validateIndividually = false, + keepRejectedFiles = false, defaultFiles = [], onChange, onDelete, @@ -221,7 +225,7 @@ export const useFileUpload = (props: UseFileUploadProps) => { const rejectedFiles: RejectedFile[] = []; const uploadedFiles = filesArray.map((file) => { const isValidExtension = validFileType(file); - const isValidSize = !maxSize || file.size <= maxSize * 1024 ** 2; + const isValidSize = !maxSize || file.size <= maxSize; if (!isValidExtension) rejectedFiles.push({ type: 'extension', file }); if (!isValidSize) rejectedFiles.push({ type: 'size', file }); @@ -258,8 +262,11 @@ export const useFileUpload = (props: UseFileUploadProps) => { let newFiles: FileUploadFile[]; if (!multiple) { - newFiles = uploadedFiles.length > 0 && uploadedFiles[0].isValid ? [uploadedFiles[0]] : actualFiles; - } else if (validateIndividually) { + // Single upload replaces the current file. Keep an invalid pick (shown under the dropzone) + // only when `keepRejectedFiles` is set; otherwise drop it and leave the list unchanged. + const [picked] = uploadedFiles; + newFiles = picked && (picked.isValid || keepRejectedFiles) ? [picked] : actualFiles; + } else if (keepRejectedFiles) { const addable = dedupe(uploadedFiles); newFiles = addable.length > 0 ? [...actualFiles, ...addable] : actualFiles; } else {