Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion component.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 4 additions & 4 deletions skills/tedi-react/references/forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<FileUpload
id="docs"
name="documents"
label="Upload documents"
accept=".pdf,.doc"
multiple
maxSize={5}
maxSize={5 * 1024 ** 2}
files={files}
onChange={setFiles}
onDelete={handleDelete}
Expand All @@ -427,13 +427,13 @@ import { FileUpload, FileDropzone } from '@tedi-design-system/react/tedi';
label="Drop files here"
accept=".pdf,.doc"
multiple
maxSize={10}
maxSize={10 * 1024 ** 2}
/>
```

**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
<FileDropzone label="Drop files" accept=".pdf,.txt" maxSize={5} tooltip="PDF/TXT, max 5 MB" showRestrictions={false} />
<FileDropzone label="Drop files" accept=".pdf,.txt" maxSize={5 * 1024 ** 2} tooltip="PDF/TXT, max 5 MB" showRestrictions={false} />
```

**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.
Expand Down
20 changes: 10 additions & 10 deletions src/tedi/components/form/file-dropzone/file-dropzone.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,12 @@ describe('FileDropzone', () => {
fileInputRef: { current: null },
});

render(<FileDropzone id="3b" name="file" label="Upload File" multiple validateIndividually />);
render(<FileDropzone id="3b" name="file" label="Upload File" multiple keepRejectedFiles />);

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' },
Expand All @@ -103,9 +103,9 @@ describe('FileDropzone', () => {
fileInputRef: { current: null },
});

render(<FileDropzone id="3d" name="file" label="Upload File" validateIndividually />);
render(<FileDropzone id="3d" name="file" label="Upload File" keepRejectedFiles />);

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', () => {
Expand All @@ -124,7 +124,7 @@ describe('FileDropzone', () => {
id="3c"
name="file"
label="Upload File"
validateIndividually
keepRejectedFiles
helper={{ type: 'error', text: 'Required' }}
/>
);
Expand Down Expand Up @@ -188,7 +188,7 @@ describe('FileDropzone', () => {
fileInputRef: { current: null },
});

render(<FileDropzone id="5" name="file" label="Upload File" accept="image/png" multiple maxSize={5} />);
render(<FileDropzone id="5" name="file" label="Upload File" accept="image/png" multiple maxSize={5 * 1024 ** 2} />);

const useDropzoneMock = useDropzone as jest.Mock;
const dropzoneProps = useDropzoneMock.mock.calls[0][0];
Expand Down Expand Up @@ -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}
Expand All @@ -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');
});
Expand Down
18 changes: 9 additions & 9 deletions src/tedi/components/form/file-dropzone/file-dropzone.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
Expand All @@ -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.',
Expand All @@ -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' }],
Expand All @@ -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' },
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -200,7 +200,7 @@ export const States: Story = {
<FileDropzone
id={`state-${label}`}
name={`state-${label}`}
maxSize={30}
maxSize={30 * 1024 ** 2}
className={className}
{...props}
/>
Expand Down
8 changes: 4 additions & 4 deletions src/tedi/components/form/file-dropzone/file-dropzone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const FileDropzone = (props: FileDropzoneProps): JSX.Element => {
accept,
maxSize,
multiple,
validateIndividually,
keepRejectedFiles,
defaultFiles,
files,
onChange,
Expand All @@ -108,7 +108,7 @@ export const FileDropzone = (props: FileDropzoneProps): JSX.Element => {
accept,
maxSize,
multiple,
validateIndividually,
keepRejectedFiles,
defaultFiles,
files,
onChange,
Expand All @@ -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;

Expand All @@ -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;

Expand Down
16 changes: 8 additions & 8 deletions src/tedi/components/form/file-upload/file-upload.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('FileUpload component', () => {
onDelete: jest.fn(),
accept: '.jpg,.png',
multiple: true,
maxSize: 5,
maxSize: 5 * 1024 ** 2,
label: 'Upload files',
};

Expand Down Expand Up @@ -75,8 +75,8 @@ describe('FileUpload component', () => {
render(
<FileUpload
{...defaultProps}
validateIndividually
maxSize={0.00001}
keepRejectedFiles
maxSize={0.00001 * 1024 ** 2}
defaultFiles={[{ name: 'ok.jpg', id: 'v1' }]}
/>
);
Expand Down Expand Up @@ -110,7 +110,7 @@ describe('FileUpload component', () => {
});

it('formats the max-size hint into readable units', () => {
render(<FileUpload {...defaultProps} accept={undefined} maxSize={0.5} />);
render(<FileUpload {...defaultProps} accept={undefined} maxSize={0.5 * 1024 ** 2} />);
expect(screen.getByText(/file-upload.max-size 512 KB/i)).toBeInTheDocument();
});

Expand Down Expand Up @@ -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(<FileUpload {...props} />);
expect(screen.getByText(/file-upload.accept .jpg, .png/i)).toBeInTheDocument();
expect(screen.getByText(/file-upload.max-size 5 MB/i)).toBeInTheDocument();
Expand Down Expand Up @@ -305,8 +305,8 @@ describe('FileUpload component', () => {
expect(screen.getByText('test.jpg')).toBeInTheDocument();
});

it('should add valid files individually when validateIndividually is true', () => {
render(<FileUpload {...defaultProps} validateIndividually />);
it('should add valid files individually when keepRejectedFiles is true', () => {
render(<FileUpload {...defaultProps} keepRejectedFiles />);
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' });
Expand Down Expand Up @@ -338,7 +338,7 @@ describe('FileUpload component', () => {
});

it('should handle file size validation correctly', () => {
render(<FileUpload {...defaultProps} maxSize={5} />);
render(<FileUpload {...defaultProps} maxSize={5 * 1024 ** 2} />);

const input = screen.getByLabelText(/Upload files/i);
const largeFile = new File(['a'.repeat(6 * 1024 * 1024)], 'large.jpg', {
Expand Down
16 changes: 8 additions & 8 deletions src/tedi/components/form/file-upload/file-upload.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
Expand All @@ -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) => (
Expand All @@ -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' },
Expand Down Expand Up @@ -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,
},
};
Expand All @@ -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,
},
Expand Down
13 changes: 8 additions & 5 deletions src/tedi/components/form/file-upload/file-upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,16 @@ export interface FileUploadProps extends Omit<FormLabelProps, 'id' | 'label'> {
*/
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
Expand Down Expand Up @@ -124,7 +127,7 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => {
readOnly,
disabled = false,
maxSize,
validateIndividually = false,
keepRejectedFiles = false,
showRestrictions,
size = 'default',
helper,
Expand All @@ -135,7 +138,7 @@ export const FileUpload = (props: FileUploadProps): JSX.Element => {
accept,
maxSize,
multiple,
validateIndividually,
keepRejectedFiles,
defaultFiles,
onChange,
onDelete,
Expand Down
11 changes: 11 additions & 0 deletions src/tedi/helpers/hooks/use-file-upload.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading