Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

file-input component #733

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
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
38 changes: 23 additions & 15 deletions apps/rhc-templates/src/app/form/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,16 @@ import {
FormFieldRadioOption,
FormFieldCheckboxOption,
Icon,
FileInput,
UnorderedList,
UnorderedListItem,
} from '@rijkshuisstijl-community/components-react';
// import { DateInput, FileInput } from '@amsterdam/design-system-react';
import { FormLabel } from '@utrecht/component-library-react';
// import { DateInput } from '@amsterdam/design-system-react';

export default function Form() {
const SIZE_IN_BYTES_10_MB = 10485760;

return (
<>
<div className="rhc-background-color-white rhc-main-content">
Expand Down Expand Up @@ -101,20 +107,22 @@ export default function Form() {
<FormFieldRadioOption label="Label" />
</div>
</Fieldset>
{/*
<div className="unstarted">
<FormField label="Bestand toevoegen">
<UnorderedList>
<UnorderedListItem>U kunt meerdere bestanden tegelijk toevoegen.</UnorderedListItem>
<UnorderedListItem>U mag maximaal 10 Mb aan bestanden toevoegen.</UnorderedListItem>
<UnorderedListItem>
Toegestane bestandstypen: doc, docx, xslx, pdf, zip, jpg, png, bpm en gif.
</UnorderedListItem>
</UnorderedList>
<FileInput multiple></FileInput>
</FormField>
</div>
*/}
<FileInput
allowedFileTypes=".doc, .docx, .xlsx, .pdf, .zip, .jpg, .png, .bmp, .gif"
buttonText="Bestand kiezen"
fileSizeErrorMessage="Dit bestand is groter dan 10 MB."
fileTypeErrorMessage="Dit bestandstype wordt niet toegestaan."
maxFileSizeInBytes={SIZE_IN_BYTES_10_MB}
>
<FormLabel>Bestand toevoegen</FormLabel>
<UnorderedList>
<UnorderedListItem>U kunt meerdere bestanden tegelijk toevoegen.</UnorderedListItem>
<UnorderedListItem>Een bestand mag maximaal 10MB groot zijn.</UnorderedListItem>
<UnorderedListItem>
Toegestane bestandstypen: doc, docx, xslx, pdf, zip, jpg, png, bmp en gif.
</UnorderedListItem>
</UnorderedList>
</FileInput>
<Heading level={1}>Informatie over de verwerking van uw persoonsgegevens</Heading>
<Paragraph>
Wij gebruiken gegevens die u heeft ingevuld om uw vraag te beantwoorden. Daarna worden ze volgens in de
Expand Down
49 changes: 49 additions & 0 deletions packages/components-css/file-input/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
.rhc-file-input {
display: flex;
flex-direction: column;
gap: var(--rhc-file-input-column-gap);
}

.rhc-file-input__item {
border-color: var(--rhc-file-input-item-border-color);
border-radius: var(--rhc-file-input-item-border-radius);
border-style: var(--rhc-file-input-item-border-style);
display: flex;
flex-direction: column;
padding-block-end: var(--rhc-file-input-item-padding-block-end);
padding-block-start: var(--rhc-file-input-item-padding-block-start);
padding-inline-end: var(--rhc-file-input-item-padding-inline-end);
padding-inline-start: var(--rhc-file-input-item-padding-inline-start);
}

.rhc-file-input__item--error {
border-color: var(--rhc-file-input-item-error-border-color);
}

.rhc-file-input__item--subtitle {
color: var(--rhc-file-input-item-subtitle-color);
}

.rhc-file-input__items-container {
display: flex;
flex-direction: column;
gap: var(--rhc-file-input-item-column-gap);
}

.rhc-file-input__inner-container {
display: flex;
flex-direction: row;
justify-content: space-between;
}

.rhc-file-input__inner-container__sub {
display: flex;
flex-direction: column;
overflow: hidden;
}

.rhc-file-input__item--name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
1 change: 1 addition & 0 deletions packages/components-css/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
@import "checkbox/index";
@import "fieldset/index";
@import "figure/index";
@import "file-input/index";
@import "form/index";
@import "form-field/index";
@import "form-field-error-message/index";
Expand Down
104 changes: 104 additions & 0 deletions packages/components-react/src/FileInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import '@testing-library/jest-dom';

import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FileInput, FileInputProps } from './FileInput';

describe('File Input tests', () => {
const defaultProps: FileInputProps = {
buttonText: 'Bestanden kiezen',
maxFileSizeInBytes: 10_485_760,
allowedFileTypes: '.doc,.docx,.xlsx,.pdf,.zip,.jpg,.png,.bmp,.gif',
fileSizeErrorMessage: 'Dit bestand is groter dan 10 MB.',
fileTypeErrorMessage: 'Dit bestandstype wordt niet toegestaan.',
};

it('renders the FileInput element', () => {
const { container } = render(<FileInput {...defaultProps} />);

const field = container.querySelector('div');

expect(field).toBeInTheDocument();
expect(field?.className).toEqual('rhc-file-input');
});

it('should be able to upload one file', async () => {
const mockOnFileChange = jest.fn();

const propsTest: FileInputProps = {
buttonText: 'Bestanden kiezen',
maxFileSizeInBytes: 10_485_760,
allowedFileTypes: '.doc,.docx,.xlsx,.pdf,.zip,.jpg,.png,.bmp,.gif',
fileSizeErrorMessage: 'Dit bestand is groter dan 10 MB.',
fileTypeErrorMessage: 'Dit bestandstype wordt niet toegestaan.',
onValueChange: mockOnFileChange,
};

const { container } = render(<FileInput {...propsTest} />);

const file = new File(['dummy content'], 'example.png', { type: 'image/png' });
const fileInput = container.querySelector('input');

await waitFor(() =>
fireEvent.change(fileInput!, {
target: { files: [file] },
}),
);

expect(mockOnFileChange).toHaveBeenCalledTimes(1);
expect(fileInput!.files![0].name).toBe(file.name);
});

it('should be able to upload multiple files in once', async () => {
const mockOnFileChange = jest.fn();

const propsTest: FileInputProps = {
buttonText: 'Bestanden kiezen',
maxFileSizeInBytes: 10_485_760,
allowedFileTypes: '.doc,.docx,.xlsx,.pdf,.zip,.jpg,.png,.bmp,.gif',
fileSizeErrorMessage: 'Dit bestand is groter dan 10 MB.',
fileTypeErrorMessage: 'Dit bestandstype wordt niet toegestaan.',
onValueChange: mockOnFileChange,
};

const { container } = render(<FileInput {...propsTest} />);

const fileOne = new File(['dummy content'], 'exampleOne.png', { type: 'image/png' });
const fileTwo = new File(['dummy content'], 'exampleTwo.png', { type: 'image/png' });

const fileInput = container.querySelector('input');

await waitFor(() =>
fireEvent.change(fileInput!, {
target: { files: [fileOne, fileTwo] },
}),
);

expect(mockOnFileChange).toHaveBeenCalledTimes(1);
expect(fileInput!.files![0].name).toBe(fileOne.name);
expect(fileInput!.files![1].name).toBe(fileTwo.name);
});

it('should not accept files not in the list', async () => {
const mockOnFileChange = jest.fn();

const propsTest: FileInputProps = {
buttonText: 'Bestanden kiezen',
maxFileSizeInBytes: 10_485_760,
allowedFileTypes: '.doc,.docx,.xlsx,.pdf,.zip,.jpg,.bmp,.gif', // Removed .png from allow list
fileSizeErrorMessage: 'Dit bestand is groter dan 10 MB.',
fileTypeErrorMessage: 'Dit bestandstype wordt niet toegestaan.',
onValueChange: mockOnFileChange,
};

const { container } = render(<FileInput {...propsTest} />);

const fileOne = new File(['dummy content'], 'exampleOne.png', { type: 'image/png' });

const fileInput = container.querySelector('input');

await waitFor(() => userEvent.upload(fileInput!, fileOne));

expect(mockOnFileChange).toHaveBeenCalledTimes(0);
});
MeesD94 marked this conversation as resolved.
Show resolved Hide resolved
});
80 changes: 80 additions & 0 deletions packages/components-react/src/FileInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { ChangeEvent, ForwardedRef, forwardRef, PropsWithChildren, useRef, useState } from 'react';
import { Button, ButtonProps } from './Button';
import { FileInputItem } from './FileInputItem';

export interface FileInputProps extends Omit<ButtonProps, 'appearance'> {
buttonText: string;
buttonAppearance?: ButtonProps['appearance'];
maxFileSizeInBytes: number;
allowedFileTypes: string;
fileSizeErrorMessage: string;
fileTypeErrorMessage: string;
onValueChange?: (callbackFiles: File[]) => void; // eslint-disable-line no-unused-vars
}

export const FileInput = forwardRef(
(
{
children,
buttonText,
maxFileSizeInBytes,
allowedFileTypes,
buttonAppearance,
fileSizeErrorMessage,
fileTypeErrorMessage,
onValueChange,
}: PropsWithChildren<FileInputProps>,
ref: ForwardedRef<HTMLDivElement>,
) => {
const [files, setFiles] = useState<File[]>([]);
const inputElement = useRef<HTMLInputElement | null>(null);
const onChange = (newFiles: FileList | null) => {
if (newFiles) {
const updatedFiles = [...files, ...Array.from(newFiles)];
setFiles(updatedFiles);
if (onValueChange) {
onValueChange(updatedFiles);
}
}
};

return (
<div className="rhc-file-input" ref={ref}>
{children}
<input
multiple
accept={allowedFileTypes}
ref={inputElement}
style={{ display: 'none' }}
type="file"
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.files);
}}
/>
<Button
appearance={buttonAppearance ?? 'secondary-action-button'}
onClick={() => inputElement.current && inputElement.current.click()}
>
{buttonText}
</Button>
<div className="rhc-file-input__items-container">
{files.map((item: File) => {
return (
<FileInputItem
allowedFileTypes={allowedFileTypes}
file={item}
fileSizeErrorMessage={fileSizeErrorMessage}
fileTypeErrorMessage={fileTypeErrorMessage}
key={files.indexOf(item)}
maxFileSizeInBytes={maxFileSizeInBytes}
onDelete={(fileToRemove: File) => setFiles(files.filter((file) => file !== fileToRemove))}
/>
);
})}
</div>
</div>
);
},
);

FileInput.displayName = 'FileInput';
64 changes: 64 additions & 0 deletions packages/components-react/src/FileInputItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Button } from '@utrecht/component-library-react';
import clsx from 'clsx';
import { Alert } from './Alert';
import { Icon } from './icon/Icon';

interface FileInputItemProps {
file: File;
onDelete: any;
maxFileSizeInBytes: number;
allowedFileTypes: string;
fileSizeErrorMessage: string;
fileTypeErrorMessage: string;
}

export const FileInputItem = ({
file,
onDelete,
maxFileSizeInBytes,
allowedFileTypes,
fileSizeErrorMessage,
fileTypeErrorMessage,
}: FileInputItemProps) => {
const extractFileTypeShort = (fileType: string): string => fileType.split('/')[1];
let error: boolean = false;
let errorMessage: string = '';

const checkFileSize = (file: File) =>
file.size <= maxFileSizeInBytes || ((errorMessage = fileSizeErrorMessage), (error = true), false);

const checkFileType = (file: File) =>
allowedFileTypes.split(',').includes(`.${extractFileTypeShort(file.type)}`) ||
((errorMessage = fileTypeErrorMessage), (error = true), false);

const formatBytes = (bytes: number): string => {
MeesD94 marked this conversation as resolved.
Show resolved Hide resolved
const kilobytes: number = bytes / 1024;
const megabytes: number = kilobytes / 1024;

return megabytes >= 1 ? `${megabytes.toFixed(1)} MB` : `${kilobytes.toFixed(1)} KB`;
};

return (
<div
className={clsx('rhc-file-input__item', {
'rhc-file-input__item--error': checkFileSize(file) === false || checkFileType(file) === false,
})}
>
<div className="rhc-file-input__inner-container">
<div className="rhc-file-input__inner-container__sub">
<span className="rhc-file-input__item--name">{file.name}</span>
<span className="rhc-file-input__item--subtitle">
({extractFileTypeShort(file.type)}, {formatBytes(file.size)})
</span>
</div>
<span>
<Button appearance="subtle-button" onClick={() => onDelete(file)}>
<Icon icon="verwijderen" />
Verwijder
</Button>
</span>
</div>
{error && <Alert textContent={errorMessage} type="error" />}
</div>
);
};
1 change: 1 addition & 0 deletions packages/components-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export {
} from './DataList';
export { Fieldset, FieldsetLegend, type FieldsetLegendProps, type FieldsetProps } from './Fieldset';
export { Figure, FigureCaption, type FigureCaptionProps, type FigureProps } from './Figure';
export { FileInput, type FileInputProps } from './FileInput';
export { Footer } from './Footer';
export { FormField, type FormFieldProps } from './FormField';
export { FormFieldCheckboxGroup, type FormFieldCheckboxGroupProps } from './FormFieldCheckboxGroup';
Expand Down
7 changes: 7 additions & 0 deletions packages/storybook/src/community/file-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Rijkshuisstijl Community File Input component

Met de `fileInput` component kunnen gebruikers meerdere bestanden uploaden.

De component accepteert `children` en kan dus op verschillende manier worden gestyled.

Dit component heeft een callbackfunctie genaamd `onFilesChange`. Hiermee kan de parent component op de hoogte worden gesteld zodra er een nieuw bestand is toegevoegd.
Loading