✨(react) add file uploader
Add mono and multi file uploader according to sketches.
This commit is contained in:
5
.changeset/sweet-actors-behave.md
Normal file
5
.changeset/sweet-actors-behave.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@openfun/cunningham-react": minor
|
||||
---
|
||||
|
||||
add file uploader
|
||||
161
packages/react/src/components/Forms/FileUploader/DropZone.tsx
Normal file
161
packages/react/src/components/Forms/FileUploader/DropZone.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
PropsWithChildren,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
import { useCunningham } from ":/components/Provider";
|
||||
import { replaceInputFilters } from ":/components/Forms/FileUploader/utils";
|
||||
import { Loader } from ":/components/Loader";
|
||||
import {
|
||||
FileUploaderProps,
|
||||
FileUploaderRefType,
|
||||
} from ":/components/Forms/FileUploader/index";
|
||||
|
||||
interface DropZoneProps extends FileUploaderProps, PropsWithChildren {
|
||||
files: File[];
|
||||
}
|
||||
|
||||
export const DropZone = forwardRef<FileUploaderRefType, DropZoneProps>(
|
||||
(
|
||||
{
|
||||
multiple,
|
||||
name,
|
||||
state,
|
||||
icon,
|
||||
animateIcon,
|
||||
successIcon,
|
||||
uploadingIcon,
|
||||
text,
|
||||
bigText,
|
||||
files,
|
||||
onFilesChange,
|
||||
children,
|
||||
...props
|
||||
}: DropZoneProps,
|
||||
ref
|
||||
) => {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const container = useRef<HTMLLabelElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useCunningham();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
get input() {
|
||||
return inputRef.current;
|
||||
},
|
||||
reset() {
|
||||
onFilesChange?.({ target: { value: [] } });
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
replaceInputFilters(inputRef.current, files);
|
||||
}, [files]);
|
||||
|
||||
useEffect(() => {
|
||||
onFilesChange?.({ target: { value: files ?? [] } });
|
||||
}, [files]);
|
||||
|
||||
const renderIcon = () => {
|
||||
if (state === "success") {
|
||||
return successIcon ?? <span className="material-icons">done</span>;
|
||||
}
|
||||
if (state === "uploading") {
|
||||
return React.cloneElement(uploadingIcon ?? <Loader size="small" />, {
|
||||
"aria-label": t("components.forms.file_uploader.uploading"),
|
||||
});
|
||||
}
|
||||
return icon ?? <span className="material-icons">upload</span>;
|
||||
};
|
||||
|
||||
const renderCaption = () => {
|
||||
if (state === "uploading") {
|
||||
return t("components.forms.file_uploader.uploading");
|
||||
}
|
||||
if (bigText) {
|
||||
return bigText;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{t("components.forms.file_uploader.caption")}
|
||||
<span>{t("components.forms.file_uploader.browse_files")}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className={classNames(
|
||||
"c__file-uploader",
|
||||
"c__file-uploader--" + state,
|
||||
{
|
||||
"c__file-uploader--active": dragActive,
|
||||
"c__file-uploader--animate-icon": animateIcon,
|
||||
}
|
||||
)}
|
||||
onDragEnter={() => {
|
||||
setDragActive(true);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
/**
|
||||
* This condition is important because onDragLeave is called when the cursor goes over
|
||||
* a child of the current node, which is not intuitive. So here we need to make sure that
|
||||
* the relatedTarget is not a child of the current node.
|
||||
*/
|
||||
if (!container.current!.contains(e.relatedTarget as Node)) {
|
||||
setDragActive(false);
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
// To prevent a new tab to open.
|
||||
e.preventDefault();
|
||||
const newFiles = Array.from(e.dataTransfer.files);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.files = e.dataTransfer.files;
|
||||
onFilesChange?.({ target: { value: [...newFiles] } });
|
||||
}
|
||||
setDragActive(false);
|
||||
}}
|
||||
ref={container}
|
||||
>
|
||||
<div className="c__file-uploader__inner">
|
||||
<div className="c__file-uploader__inner__icon">{renderIcon()}</div>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="c__file-uploader__inner__caption">
|
||||
{renderCaption()}
|
||||
</div>
|
||||
{text && (
|
||||
<div className="c__file-uploader__inner__text">{text}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
name={name}
|
||||
ref={inputRef}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
onFilesChange?.({ target: { value: [...e.target.files] } });
|
||||
} else {
|
||||
onFilesChange?.({ target: { value: [] } });
|
||||
}
|
||||
}}
|
||||
multiple={multiple}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { forwardRef, useEffect, useMemo, useState } from "react";
|
||||
import { useCunningham } from ":/components/Provider";
|
||||
import { Button } from ":/components/Button";
|
||||
import {
|
||||
FileUploaderProps,
|
||||
FileUploaderRefType,
|
||||
} from ":/components/Forms/FileUploader/index";
|
||||
import { DropZone } from ":/components/Forms/FileUploader/DropZone";
|
||||
|
||||
export const FileUploaderMono = forwardRef<
|
||||
FileUploaderRefType,
|
||||
FileUploaderProps
|
||||
>(({ fakeDefaultFiles, ...props }, ref) => {
|
||||
const { t } = useCunningham();
|
||||
const [file, setFile] = useState<File | undefined>(
|
||||
fakeDefaultFiles && fakeDefaultFiles.length > 0
|
||||
? fakeDefaultFiles[0]
|
||||
: undefined
|
||||
);
|
||||
// This is made to prevent useEffects inside DropZone that depends on `files` to trigger on each render,
|
||||
// doing this preserves the reference of the array.
|
||||
const files = useMemo(() => (file ? [file] : []), [file]);
|
||||
const [hoverDelete, setHoverDelete] = useState(false);
|
||||
const [icon, animateIcon] = useMemo(() => {
|
||||
if (hoverDelete) {
|
||||
return [
|
||||
props.deleteIcon ?? <span className="material-icons">delete</span>,
|
||||
true,
|
||||
];
|
||||
}
|
||||
if (file) {
|
||||
return [
|
||||
props.fileSelectedIcon ?? (
|
||||
<span className="material-icons">download</span>
|
||||
),
|
||||
false,
|
||||
];
|
||||
}
|
||||
return [props.icon, true];
|
||||
}, [file, hoverDelete]);
|
||||
|
||||
const deleteFile = (e: React.MouseEvent<HTMLElement>) => {
|
||||
setFile(undefined);
|
||||
// This is to prevent opening the browse file window.
|
||||
e.preventDefault();
|
||||
setHoverDelete(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
props.onFilesChange?.({ target: { value: file ? [file] : [] } });
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropZone
|
||||
{...props}
|
||||
files={files}
|
||||
onFilesChange={(e) => setFile(e.target.value[0])}
|
||||
icon={icon}
|
||||
animateIcon={animateIcon}
|
||||
ref={ref}
|
||||
>
|
||||
{file && (
|
||||
<>
|
||||
<div className="c__file-uploader__inner__filename">{file.name}</div>
|
||||
{/* We cannot use Button component for "Delete file" because it is wrapper inside a label, when clicking */}
|
||||
{/* on the label if a button is wrapped inside it triggers the button click too. But in our situation if a file has */}
|
||||
{/* already been choosen, clicking on the label should trigger the browse file window and not JUST remove the file. ( We */}
|
||||
{/* must click specifically on the button to remove the file ) */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="c__file-uploader__inner__actions"
|
||||
onClick={deleteFile}
|
||||
onMouseEnter={() => {
|
||||
setHoverDelete(true);
|
||||
}}
|
||||
onMouseLeave={() => setHoverDelete(false)}
|
||||
>
|
||||
{t("components.forms.file_uploader.delete_file")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropZone>
|
||||
{file && (
|
||||
<>
|
||||
{/* This one is for a11y purposes. */}
|
||||
<Button onClick={deleteFile} className="c__offscreen">
|
||||
{t("components.forms.file_uploader.delete_file")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { forwardRef, useEffect, useState } from "react";
|
||||
import { useCunningham } from ":/components/Provider";
|
||||
import { formatBytes } from ":/components/Forms/FileUploader/utils";
|
||||
import { Button } from ":/components/Button";
|
||||
import {
|
||||
FileUploaderProps,
|
||||
FileUploaderRefType,
|
||||
} from ":/components/Forms/FileUploader/index";
|
||||
import { DropZone } from ":/components/Forms/FileUploader/DropZone";
|
||||
|
||||
export const FileUploaderMulti = forwardRef<
|
||||
FileUploaderRefType,
|
||||
FileUploaderProps
|
||||
>(({ fullWidth, fakeDefaultFiles, ...props }, ref) => {
|
||||
const { t } = useCunningham();
|
||||
const [files, setFiles] = useState<File[]>(fakeDefaultFiles || []);
|
||||
|
||||
useEffect(() => {
|
||||
props.onFilesChange?.({ target: { value: files } });
|
||||
}, [files]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropZone
|
||||
{...props}
|
||||
files={files}
|
||||
onFilesChange={(e) => setFiles(e.target.value)}
|
||||
animateIcon={true}
|
||||
ref={ref}
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<div className="c__file-uploader__files">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
className="c__file-uploader__file"
|
||||
key={"" + file.name + file.size + file.type + file.lastModified}
|
||||
>
|
||||
<div className="c__file-uploader__file__name">{file.name}</div>
|
||||
<div className="c__file-uploader__file__specs">
|
||||
<span>{formatBytes(file.size)}</span>
|
||||
<Button
|
||||
color="tertiary"
|
||||
size="small"
|
||||
aria-label={t(
|
||||
"components.forms.file_uploader.delete_file_name",
|
||||
{
|
||||
name: file.name,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
setFiles(files.filter((f) => f !== file));
|
||||
}}
|
||||
icon={<span className="material-icons">close</span>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
123
packages/react/src/components/Forms/FileUploader/index.mdx
Normal file
123
packages/react/src/components/Forms/FileUploader/index.mdx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ArgTypes, Canvas, Meta, Source, Story } from '@storybook/blocks';
|
||||
import * as Stories from './index.stories';
|
||||
import { FileUploader } from './index';
|
||||
|
||||
<Meta of={Stories}/>
|
||||
|
||||
# FileUploader
|
||||
|
||||
Cunningham provides a file uploader component that you can use in your forms.
|
||||
|
||||
|
||||
<Canvas>
|
||||
<Story id="components-forms-fileuploader--mono"/>
|
||||
</Canvas>
|
||||
|
||||
<Source
|
||||
language='ts'
|
||||
dark
|
||||
format={false}
|
||||
code={`import { FileUploader } from "@openfun/cunningham-react";`}
|
||||
/>
|
||||
|
||||
## Multi
|
||||
|
||||
The file uploader comes with a multi version to handle multiple files.
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-fileuploader--multi-wih-files"/>
|
||||
</Canvas>
|
||||
|
||||
|
||||
## States
|
||||
|
||||
You can use the following props to change the state of the FileUploader component by using the `state` props.
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-fileuploader--mono-with-file-success"/>
|
||||
</Canvas>
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-fileuploader--mono-error"/>
|
||||
</Canvas>
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-fileuploader--mono-uploading"/>
|
||||
</Canvas>
|
||||
|
||||
## Texts
|
||||
|
||||
You can customize displayed texts by using `bigText` and `text` props.
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-fileuploader--mono-with-text"/>
|
||||
</Canvas>
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-fileuploader--mono-with-big-text"/>
|
||||
</Canvas>
|
||||
|
||||
## Icons
|
||||
|
||||
You can customize the icons used by the FileUploader using `icon`, `successIcon`, `deleteIcon`, `fileSelectedIcon` and `uploadingIcon`.
|
||||
|
||||
> You can also disable the icon animation on hover by using `animateIcon` props.
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-fileuploader--mono-custom-icons"/>
|
||||
</Canvas>
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-fileuploader--mono-custom-icons-success"/>
|
||||
</Canvas>
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-fileuploader--mono-custom-icons-uploading"/>
|
||||
</Canvas>
|
||||
|
||||
## Controlled / Non Controlled
|
||||
|
||||
This component cannot be entirely controlled like others due to browser restrictions. (For security reasons, browsers do not allow you to set an arbitrary to a file input.)
|
||||
|
||||
What you can do is to use the `onChange` callback to be aware of any changes in case you need to do something with the file(s).
|
||||
|
||||
You can also reset the input by using the `reset` method available via the ref.
|
||||
|
||||
It works the same for multiple or mono version.
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-fileuploader--multi-value"/>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
You can use all the props of the native html `<input>` element props plus the following.
|
||||
|
||||
<ArgTypes of={FileUploader} />
|
||||
|
||||
## Design tokens
|
||||
|
||||
Here are the custom design tokens defined by the button.
|
||||
|
||||
| Token | Description |
|
||||
|---------------|----------------------------- |
|
||||
| background-color | Background color of the dropzone |
|
||||
| border-color | Border color of the dropzone |
|
||||
| border-radius | Border radius of the dropzone |
|
||||
| border-width | Border width of the dropzone |
|
||||
| border-style | Border style of the dropzone |
|
||||
| background-color--active | Background color of the dropzone when active or on hover |
|
||||
| color | Color of the dropzone text |
|
||||
| padding | Padding of the dropzone |
|
||||
| accent-color | Accent color of the dropzone |
|
||||
| text-color | Color of the small text |
|
||||
| text-size | Font size of the small text |
|
||||
| file-text-size | (Multi only) Font size of the file text |
|
||||
| file-text-color | (Multi only) Font color of the file text |
|
||||
| file-text-weight | (Multi only) Font weight of the file text |
|
||||
| file-border-color | (Multi only) Border color of file |
|
||||
| file-border-width | (Multi only) Border width of file |
|
||||
| file-border-radius | (Multi only) Border radius of file |
|
||||
| file-background-color | (Multi only) Background color of file |
|
||||
| file-specs-size | (Multi only) Font size of file specs |
|
||||
| file-specs-color | (Multi only) Font color of file specs |
|
||||
|
||||
See also [Field](?path=/story/components-forms-field-doc--page)
|
||||
162
packages/react/src/components/Forms/FileUploader/index.scss
Normal file
162
packages/react/src/components/Forms/FileUploader/index.scss
Normal file
@@ -0,0 +1,162 @@
|
||||
.c__file-uploader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-width: var(--c--components--forms-fileuploader--border-width);
|
||||
border-style: var(--c--components--forms-fileuploader--border-style);
|
||||
border-color: var(--c--components--forms-fileuploader--border-color);
|
||||
border-radius: var(--c--components--forms-fileuploader--border-radius);
|
||||
color: var(--c--components--forms-fileuploader--color);
|
||||
font-weight: var(--c--theme--font--weights--light);
|
||||
padding: var(--c--components--forms-fileuploader--padding);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: border var(--c--theme--transitions--duration) var(--c--theme--transitions--ease-out);
|
||||
background-color: var(--c--components--forms-fileuploader--background-color);
|
||||
|
||||
&--success {
|
||||
border-color: var(--c--theme--colors--success-600);
|
||||
|
||||
.c__file-uploader__inner {
|
||||
&__icon {
|
||||
color: var(--c--theme--colors--success-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: var(--c--theme--colors--danger-600);
|
||||
|
||||
.c__file-uploader__inner {
|
||||
&__icon {
|
||||
color: var(--c--theme--colors--danger-600);
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: var(--c--theme--colors--danger-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__caption {
|
||||
span {
|
||||
color: var(--c--components--forms-fileuploader--accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--c--components--forms-fileuploader--text-size);
|
||||
color: var(--c--components--forms-fileuploader--text-color);
|
||||
}
|
||||
|
||||
&__filename {
|
||||
color: var(--c--components--forms-fileuploader--accent-color);
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
text-align: center;
|
||||
font-size: var(--c--components--forms-fileuploader--text-size);
|
||||
color: var(--c--components--forms-fileuploader--text-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--c--theme--colors--danger-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__files {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__file {
|
||||
background-color: var(--c--components--forms-fileuploader--file-background-color);
|
||||
border-radius: var(--c--components--forms-fileuploader--file-border-radius);
|
||||
border-style: solid;
|
||||
border-color: var(--c--components--forms-fileuploader--file-border-color);
|
||||
border-width: var(--c--components--forms-fileuploader--file-border-width);
|
||||
font-size: var(--c--components--forms-fileuploader--file-text-size);
|
||||
font-weight: var(--c--components--forms-fileuploader--file-text-weight);
|
||||
color: var(--c--components--forms-fileuploader--file-text-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
&__name {
|
||||
padding: 0.5rem;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__specs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--c--components--forms-fileuploader--file-specs-size);
|
||||
color: var(--c--components--forms-fileuploader--file-specs-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
.material-icons {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--active,
|
||||
&:hover {
|
||||
border-color: var(--c--components--forms-fileuploader--accent-color);
|
||||
background-color: var(--c--components--forms-fileuploader--background-color--active);
|
||||
|
||||
&.c__file-uploader--animate-icon {
|
||||
.c__file-uploader__inner {
|
||||
&__icon {
|
||||
animation-name: bounce-5;
|
||||
animation-timing-function: var(--c--theme--transitions--ease-out);
|
||||
animation-duration: 2s;
|
||||
animation-iteration-count: infinite;
|
||||
|
||||
@keyframes bounce-5 {
|
||||
0% {
|
||||
transform: scale(1, 1) translateY(0);
|
||||
}
|
||||
10% {
|
||||
transform: scale(1.1, 0.9) translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: scale(0.9, 1.1) translateY(-10px);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1, 1) translateY(0);
|
||||
}
|
||||
57% {
|
||||
transform: scale(1, 1) translateY(-2px);
|
||||
}
|
||||
64% {
|
||||
transform: scale(1, 1) translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1, 1) translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
579
packages/react/src/components/Forms/FileUploader/index.spec.tsx
Normal file
579
packages/react/src/components/Forms/FileUploader/index.spec.tsx
Normal file
@@ -0,0 +1,579 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CunninghamProvider } from ":/components/Provider";
|
||||
import {
|
||||
FileUploader,
|
||||
FileUploaderRefType,
|
||||
} from ":/components/Forms/FileUploader/index";
|
||||
import { Button } from ":/components/Button";
|
||||
|
||||
/**
|
||||
* There are limitations in this test:
|
||||
* jestdom does not support replacing input.files with another FileList, which is what we do in the component when
|
||||
* we delete a file from the list for example.
|
||||
* So what we do is we mock the utils module and replace the replaceInputFilters function with a no-op. The consequence
|
||||
* of this is that the real files ( as value ) of the input are not replaced.
|
||||
*/
|
||||
vi.mock("./utils", async () => {
|
||||
const actual: any = await vi.importActual("./utils");
|
||||
return { ...actual, replaceInputFilters: () => {} };
|
||||
});
|
||||
|
||||
describe("<FileUploader />", () => {
|
||||
describe("Mono", () => {
|
||||
it("should select a file and display its name", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement =
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
const file = new File(["hello"], "hello.png", { type: "image/png" });
|
||||
|
||||
await act(async () => {
|
||||
await user.upload(input, file);
|
||||
});
|
||||
|
||||
await screen.findByText("hello.png");
|
||||
expect(
|
||||
screen.queryByLabelText(/Drag and drop or /)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should select a file and delete it", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement =
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
const file = new File(["hello"], "hello.png", { type: "image/png" });
|
||||
|
||||
await act(async () => {
|
||||
await user.upload(input, file);
|
||||
});
|
||||
|
||||
await screen.findByText("hello.png");
|
||||
expect(
|
||||
screen.queryByLabelText(/Drag and drop or /)
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const deleteButton = screen.getByRole("button", {
|
||||
name: "Delete file",
|
||||
});
|
||||
await act(async () => {
|
||||
await user.click(deleteButton);
|
||||
});
|
||||
|
||||
expect(screen.queryByText("hello.png")).not.toBeInTheDocument();
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
});
|
||||
|
||||
it("should submit selected file in form", async () => {
|
||||
/**
|
||||
* At first I wanted to test the file existence in the formData with onSubmit callback,
|
||||
* but it seems that jsdom does not support
|
||||
* FormData: it always gives an empty file when using .get("file") even if input.files is fulfilled with
|
||||
* the wanted file. So I just test that the file is well set in input.files.
|
||||
*/
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader name="file" />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement =
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
const file = new File(["hello"], "hello.png", { type: "image/png" });
|
||||
|
||||
expect(input.files!.length).toEqual(0);
|
||||
|
||||
await act(async () => {
|
||||
await user.upload(input, file);
|
||||
});
|
||||
await screen.findByText("hello.png");
|
||||
expect(input.files!.length).toEqual(1);
|
||||
expect(input.files![0]).toStrictEqual(file);
|
||||
});
|
||||
|
||||
it("should be in state=uploading", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader state="uploading" />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
expect(
|
||||
document.querySelector(".c__file-uploader--uploading")
|
||||
).toBeInTheDocument();
|
||||
screen.getByText("Uploading...");
|
||||
screen.getByRole("status", { name: "Uploading..." });
|
||||
});
|
||||
|
||||
it("should be in state=success", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader state="success" />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
expect(
|
||||
document.querySelector(".c__file-uploader--success")
|
||||
).toBeInTheDocument();
|
||||
expect(document.querySelector(".material-icons")?.textContent).toContain(
|
||||
"done"
|
||||
);
|
||||
});
|
||||
|
||||
it("should be in state=error with custom bigText or filename", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader state="error" bigText="Error file is too big" />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
|
||||
// Big text is shown is file is not selected.
|
||||
expect(
|
||||
document.querySelector(".c__file-uploader--error")
|
||||
).toBeInTheDocument();
|
||||
screen.getByText("Error file is too big");
|
||||
|
||||
// Upload a file.
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement = screen.getByLabelText(
|
||||
/Error file is too big/
|
||||
);
|
||||
const file = new File(["hello"], "hello.png", { type: "image/png" });
|
||||
await act(async () => {
|
||||
await user.upload(input, file);
|
||||
});
|
||||
|
||||
// The filename is shown in place of the big text.
|
||||
expect(
|
||||
screen.queryByText("Error file is too big")
|
||||
).not.toBeInTheDocument();
|
||||
await screen.findByText("hello.png");
|
||||
});
|
||||
|
||||
it("should display text", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader text="JPG, PNG or GIF - Max file size 2MB" />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
screen.getByText("JPG, PNG or GIF - Max file size 2MB");
|
||||
});
|
||||
|
||||
it("should display custom icon, fileSelectedIcon, deleteIcon", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader
|
||||
icon={<span>custom_icon</span>}
|
||||
fileSelectedIcon={<span>file_selected_custom_icon</span>}
|
||||
deleteIcon={<span>delete_custom_icon</span>}
|
||||
/>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
screen.getByText("custom_icon");
|
||||
expect(
|
||||
screen.queryByText("file_selected_custom_icon")
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("delete_custom_icon")).not.toBeInTheDocument();
|
||||
|
||||
// Upload a file.
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement =
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
const file = new File(["hello"], "hello.png", { type: "image/png" });
|
||||
await act(async () => {
|
||||
await user.upload(input, file);
|
||||
});
|
||||
|
||||
// Now the selected custom icon must be shown.
|
||||
expect(screen.queryByText("custom_icon")).not.toBeInTheDocument();
|
||||
screen.getByText("file_selected_custom_icon");
|
||||
expect(screen.queryByText("delete_custom_icon")).not.toBeInTheDocument();
|
||||
|
||||
// Hover delete file to show the custom delete icon.
|
||||
const spanDelete = document.querySelector(
|
||||
".c__file-uploader__inner__actions"
|
||||
)!;
|
||||
await act(async () => user.hover(spanDelete));
|
||||
expect(screen.queryByText("custom_icon")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("file_selected_custom_icon")
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByText("delete_custom_icon");
|
||||
});
|
||||
|
||||
it("should display custom successIcon", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader
|
||||
successIcon={<span>custom_success_icon</span>}
|
||||
state="success"
|
||||
/>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
screen.getByText("custom_success_icon");
|
||||
});
|
||||
|
||||
it("should display custom uploadingIcon", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader
|
||||
uploadingIcon={<span>custom_uploading_icon</span>}
|
||||
state="uploading"
|
||||
/>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
screen.getByText("custom_uploading_icon");
|
||||
});
|
||||
|
||||
it("can be reset in a controlled way and triggers onChange", async () => {
|
||||
const Wrapper = () => {
|
||||
const ref = useRef<FileUploaderRefType>(null);
|
||||
const [value, setValue] = useState<File[]>([]);
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<div>
|
||||
<div>Value: {value.map((file) => file.name).join(", ")}|</div>
|
||||
<FileUploader
|
||||
onFilesChange={(e) => setValue(e.target.value)}
|
||||
ref={ref}
|
||||
/>
|
||||
<Button onClick={() => ref.current?.reset()}>Reset</Button>
|
||||
</div>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
};
|
||||
render(<Wrapper />);
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement =
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
const file = new File(["hello"], "hello.png", { type: "image/png" });
|
||||
|
||||
// No file is selected.
|
||||
screen.getByText("Value: |");
|
||||
|
||||
// Upload a file.
|
||||
await act(async () => {
|
||||
await user.upload(input, file);
|
||||
});
|
||||
screen.getByText("Value: hello.png|");
|
||||
|
||||
// Reset the file.
|
||||
const resetButton = screen.getByRole("button", { name: "Reset" });
|
||||
await act(async () => {
|
||||
await user.click(resetButton);
|
||||
});
|
||||
screen.getByText("Value: |");
|
||||
});
|
||||
});
|
||||
describe("Multi", () => {
|
||||
const expectFiles = (expectedFiles: { name: string; specs: string }[]) => {
|
||||
const actualElements = document.querySelectorAll(
|
||||
".c__file-uploader__file"
|
||||
);
|
||||
const actual = Array.from(actualElements).map((element) => {
|
||||
return {
|
||||
name: element.querySelector(".c__file-uploader__file__name")!
|
||||
.textContent,
|
||||
specs: element.querySelector(".c__file-uploader__file__specs span")!
|
||||
.textContent,
|
||||
};
|
||||
});
|
||||
expect(actual).toEqual(expectedFiles);
|
||||
};
|
||||
|
||||
it("should select files and list them", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader multiple={true} />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement =
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
const files = [
|
||||
new File(["hello"], "hello.png", { type: "image/png" }),
|
||||
new File(["foo"], "foo.png", { type: "image/png" }),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await user.upload(input, files);
|
||||
});
|
||||
|
||||
expectFiles([
|
||||
{ name: "hello.png", specs: "5 Bytes" },
|
||||
{ name: "foo.png", specs: "3 Bytes" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should select files delete them one by one", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader multiple={true} />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement =
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
const files = [
|
||||
new File(["hello"], "hello.png", { type: "image/png" }),
|
||||
new File(["foo"], "foo.png", { type: "image/png" }),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await user.upload(input, files);
|
||||
});
|
||||
|
||||
expectFiles([
|
||||
{ name: "hello.png", specs: "5 Bytes" },
|
||||
{ name: "foo.png", specs: "3 Bytes" },
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Delete file hello.png",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expectFiles([{ name: "foo.png", specs: "3 Bytes" }]);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Delete file foo.png",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expectFiles([]);
|
||||
});
|
||||
|
||||
it("should submit selected files in form", async () => {
|
||||
/**
|
||||
* At first I wanted to test the file existence in the formData with onSubmit callback,
|
||||
* but it seems that jsdom does not support
|
||||
* FormData: it always gives an empty file when using .get("file") even if input.files is fulfilled with
|
||||
* the wanted file. So I just test that the file is well set in input.files.
|
||||
*/
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader name="files" multiple={true} />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement =
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
const files = [
|
||||
new File(["hello"], "hello.png", { type: "image/png" }),
|
||||
new File(["foo"], "foo.png", { type: "image/png" }),
|
||||
];
|
||||
|
||||
expectFiles([]);
|
||||
|
||||
expect(input.files!.length).toEqual(0);
|
||||
|
||||
await act(async () => {
|
||||
await user.upload(input, files);
|
||||
});
|
||||
|
||||
expectFiles([
|
||||
{ name: "hello.png", specs: "5 Bytes" },
|
||||
{ name: "foo.png", specs: "3 Bytes" },
|
||||
]);
|
||||
expect(input.files!.length).toEqual(2);
|
||||
expect(input.files![0]).toStrictEqual(files[0]);
|
||||
expect(input.files![1]).toStrictEqual(files[1]);
|
||||
});
|
||||
|
||||
it("should remove previous selected files when choosing new files", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader name="files" multiple={true} />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement =
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
const files1 = [
|
||||
new File(["hello"], "hello.png", { type: "image/png" }),
|
||||
new File(["foo"], "foo.png", { type: "image/png" }),
|
||||
];
|
||||
const files2 = [
|
||||
new File(["bye"], "bye.png", { type: "image/png" }),
|
||||
new File(["bar"], "bar.png", { type: "image/png" }),
|
||||
];
|
||||
|
||||
expectFiles([]);
|
||||
|
||||
await act(async () => {
|
||||
await user.upload(input, files1);
|
||||
});
|
||||
|
||||
expectFiles([
|
||||
{ name: "hello.png", specs: "5 Bytes" },
|
||||
{ name: "foo.png", specs: "3 Bytes" },
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
await user.upload(input, files2);
|
||||
});
|
||||
|
||||
expectFiles([
|
||||
{ name: "bye.png", specs: "3 Bytes" },
|
||||
{ name: "bar.png", specs: "3 Bytes" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should be in state=uploading", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader state="uploading" multiple={true} />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement = document.querySelector("input")!;
|
||||
const files = [
|
||||
new File(["hello"], "hello.png", { type: "image/png" }),
|
||||
new File(["foo"], "foo.png", { type: "image/png" }),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await user.upload(input, files);
|
||||
});
|
||||
|
||||
expectFiles([
|
||||
{ name: "hello.png", specs: "5 Bytes" },
|
||||
{ name: "foo.png", specs: "3 Bytes" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
document.querySelector(".c__file-uploader--uploading")
|
||||
).toBeInTheDocument();
|
||||
screen.getByText("Uploading...");
|
||||
screen.getByRole("status", { name: "Uploading..." });
|
||||
});
|
||||
|
||||
it("should be in state=success", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader state="success" multiple={true} />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
expect(
|
||||
document.querySelector(".c__file-uploader--success")
|
||||
).toBeInTheDocument();
|
||||
expect(document.querySelector(".material-icons")?.textContent).toContain(
|
||||
"done"
|
||||
);
|
||||
});
|
||||
|
||||
it("should be in state=error", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader
|
||||
state="error"
|
||||
bigText="Error file is too big"
|
||||
multiple={true}
|
||||
/>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
|
||||
// Big text is shown is file is not selected.
|
||||
expect(
|
||||
document.querySelector(".c__file-uploader--error")
|
||||
).toBeInTheDocument();
|
||||
screen.getByText("Error file is too big");
|
||||
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement = document.querySelector("input")!;
|
||||
const files = [
|
||||
new File(["hello"], "hello.png", { type: "image/png" }),
|
||||
new File(["foo"], "foo.png", { type: "image/png" }),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await user.upload(input, files);
|
||||
});
|
||||
|
||||
expectFiles([
|
||||
{ name: "hello.png", specs: "5 Bytes" },
|
||||
{ name: "foo.png", specs: "3 Bytes" },
|
||||
]);
|
||||
|
||||
// The error is still displayed if files are selected.
|
||||
expect(
|
||||
document.querySelector(".c__file-uploader--error")
|
||||
).toBeInTheDocument();
|
||||
screen.getByText("Error file is too big");
|
||||
});
|
||||
|
||||
it("should display text", async () => {
|
||||
render(
|
||||
<CunninghamProvider>
|
||||
<FileUploader
|
||||
text="JPG, PNG or GIF - Max file size 2MB"
|
||||
multiple={true}
|
||||
/>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
screen.getByText("JPG, PNG or GIF - Max file size 2MB");
|
||||
});
|
||||
|
||||
it("can be reset in a controlled way and triggers onChange", async () => {
|
||||
const Wrapper = () => {
|
||||
const ref = useRef<FileUploaderRefType>(null);
|
||||
const [value, setValue] = useState<File[]>([]);
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<div>
|
||||
<div>Value: {value.map((file) => file.name).join(", ")}|</div>
|
||||
<FileUploader
|
||||
onFilesChange={(e) => setValue(e.target.value)}
|
||||
ref={ref}
|
||||
multiple={true}
|
||||
/>
|
||||
<Button onClick={() => ref.current?.reset()}>Reset</Button>
|
||||
</div>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
};
|
||||
render(<Wrapper />);
|
||||
const user = userEvent.setup();
|
||||
const input: HTMLInputElement =
|
||||
screen.getByLabelText(/Drag and drop or /);
|
||||
const files = [
|
||||
new File(["hello"], "hello.png", { type: "image/png" }),
|
||||
new File(["foo"], "foo.png", { type: "image/png" }),
|
||||
];
|
||||
|
||||
// No file is selected.
|
||||
screen.getByText("Value: |");
|
||||
|
||||
// Upload a file.
|
||||
await act(async () => {
|
||||
await user.upload(input, files);
|
||||
});
|
||||
screen.getByText("Value: hello.png, foo.png|");
|
||||
|
||||
// Reset the file.
|
||||
const resetButton = screen.getByRole("button", { name: "Reset" });
|
||||
await act(async () => {
|
||||
await user.click(resetButton);
|
||||
});
|
||||
screen.getByText("Value: |");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Meta, StoryFn } from "@storybook/react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import {
|
||||
FileUploader,
|
||||
FileUploaderRefType,
|
||||
} from ":/components/Forms/FileUploader/index";
|
||||
import { CunninghamProvider } from ":/components/Provider";
|
||||
import { Button } from ":/components/Button";
|
||||
|
||||
const Template: StoryFn<typeof FileUploader> = (args) => (
|
||||
<CunninghamProvider>
|
||||
<FileUploader {...args} />
|
||||
</CunninghamProvider>
|
||||
);
|
||||
|
||||
const FAKE_FILES = [
|
||||
new File(["42"], faker.lorem.sentence(5) + "pdf"),
|
||||
new File(["42"], faker.lorem.sentence(1) + "pdf"),
|
||||
new File(["42"], faker.lorem.sentence(2) + "pdf"),
|
||||
new File(["42"], faker.lorem.sentence(3) + "pdf"),
|
||||
];
|
||||
|
||||
const meta: Meta<typeof FileUploader> = {
|
||||
title: "Components/Forms/FileUploader",
|
||||
component: FileUploader,
|
||||
render: Template,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Mono = {};
|
||||
|
||||
export const MonoWithText = {
|
||||
args: {
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoWithBigText = {
|
||||
args: {
|
||||
bigText: "Hi there",
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoWithFile = {
|
||||
args: {
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoWithFileSuccess = {
|
||||
args: {
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
state: "success",
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoError = {
|
||||
args: {
|
||||
bigText: "Error file is too big",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
state: "error",
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoErrorWithFile = {
|
||||
args: {
|
||||
bigText: "Error file is too big",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
state: "error",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoUploading = {
|
||||
args: {
|
||||
bigText: "Error file is too big",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
state: "uploading",
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoCustomIcons = {
|
||||
args: {
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
icon: <span className="material-icons">add_box</span>,
|
||||
fileSelectedIcon: <span className="material-icons">attach_file</span>,
|
||||
deleteIcon: <span className="material-icons">backspace</span>,
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoCustomIconsSuccess = {
|
||||
args: {
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
state: "success",
|
||||
successIcon: <span className="material-icons">verified</span>,
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoCustomIconsUploading = {
|
||||
args: {
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
state: "uploading",
|
||||
uploadingIcon: <span className="material-icons">update</span>,
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoStatesShowcase = {
|
||||
render: () => {
|
||||
const stepToProps: Record<string, any> = {
|
||||
default: {
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
},
|
||||
fileSelected: {
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
},
|
||||
uploading: {
|
||||
state: "uploading",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
},
|
||||
success: {
|
||||
state: "success",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
},
|
||||
error: {
|
||||
state: "error",
|
||||
bigText: "Error file is too big",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
},
|
||||
};
|
||||
const steps = Object.keys(stepToProps);
|
||||
const [step, setStep] = React.useState(steps[0]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setStep(steps[(steps.indexOf(step) + 1) % steps.length]);
|
||||
}, 2000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [step]);
|
||||
|
||||
// The key is here to re-render the component when the state changes only when we want to display
|
||||
// the fake default files. In all other step we want it to be persistant in order to see transitions ( like the
|
||||
// border color ).
|
||||
return (
|
||||
<Template
|
||||
key={step === "fileSelected" ? step : "const"}
|
||||
{...stepToProps[step]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MonoValue = {
|
||||
render: () => {
|
||||
const ref = useRef<FileUploaderRefType>(null);
|
||||
const [value, setValue] = useState<File[]>([]);
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<div>
|
||||
<div>Value: {value.map((file) => file.name).join(", ")}</div>
|
||||
<FileUploader
|
||||
onFilesChange={(e) => setValue(e.target.value)}
|
||||
ref={ref}
|
||||
/>
|
||||
<Button onClick={() => ref.current?.reset()}>Reset</Button>
|
||||
</div>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Multi = {
|
||||
args: {
|
||||
multiple: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiWihText = {
|
||||
args: {
|
||||
multiple: true,
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiWihFiles = {
|
||||
args: {
|
||||
multiple: true,
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiWithFileSuccess = {
|
||||
args: {
|
||||
multiple: true,
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
state: "success",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiError = {
|
||||
args: {
|
||||
multiple: true,
|
||||
bigText: "Error file is too big",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
state: "error",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiErrorWithFile = {
|
||||
args: {
|
||||
multiple: true,
|
||||
bigText: "Error file is too big",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
state: "error",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiUploadingWithFiles = {
|
||||
args: {
|
||||
multiple: true,
|
||||
bigText: "Error file is too big",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
state: "uploading",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiStatesShowcase = {
|
||||
render: () => {
|
||||
const stepToProps: Record<string, any> = {
|
||||
default: {
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
},
|
||||
fileSelected: {
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
},
|
||||
uploading: {
|
||||
state: "uploading",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
},
|
||||
success: {
|
||||
state: "success",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
},
|
||||
error: {
|
||||
state: "error",
|
||||
bigText: "Error file is too big",
|
||||
text: "JPG, PNG or GIF - Max file size 2MB",
|
||||
fakeDefaultFiles: FAKE_FILES,
|
||||
},
|
||||
};
|
||||
const steps = Object.keys(stepToProps);
|
||||
const [step, setStep] = React.useState(steps[0]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setStep(steps[(steps.indexOf(step) + 1) % steps.length]);
|
||||
}, 2000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [step]);
|
||||
|
||||
// The key is here to re-render the component when the state changes only when we want to display
|
||||
// the fake default files. In all other step we want it to be persistant in order to see transitions ( like the
|
||||
// border color ).
|
||||
return (
|
||||
<Template
|
||||
key={step === "fileSelected" ? step : "const"}
|
||||
multiple={true}
|
||||
{...stepToProps[step]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiValue = {
|
||||
render: () => {
|
||||
const ref = useRef<FileUploaderRefType>(null);
|
||||
const [value, setValue] = useState<File[]>([]);
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<div>
|
||||
<div>Value: {value.map((file) => file.name).join(", ")}</div>
|
||||
<FileUploader
|
||||
onFilesChange={(e) => setValue(e.target.value)}
|
||||
ref={ref}
|
||||
multiple={true}
|
||||
/>
|
||||
<Button onClick={() => ref.current?.reset()}>Reset</Button>
|
||||
</div>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Form = {
|
||||
render: () => {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target as any);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("SUBMIT", data.getAll("files"));
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("SUBMIT", data.getAll("file"));
|
||||
};
|
||||
|
||||
return (
|
||||
<CunninghamProvider>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<FileUploader
|
||||
name="files"
|
||||
text="JPG, PNG or GIF - Max file size 2MB"
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
multiple={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-s">
|
||||
<FileUploader
|
||||
name="file"
|
||||
text="JPG, PNG or GIF - Max file size 2MB"
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-s">
|
||||
<Button>Submit</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CunninghamProvider>
|
||||
);
|
||||
},
|
||||
};
|
||||
41
packages/react/src/components/Forms/FileUploader/index.tsx
Normal file
41
packages/react/src/components/Forms/FileUploader/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { forwardRef, InputHTMLAttributes, ReactElement } from "react";
|
||||
import { Field, FieldProps, FieldState } from ":/components/Forms/Field";
|
||||
import { InputRefType } from ":/components/Forms/Input";
|
||||
import { FileUploaderMulti } from ":/components/Forms/FileUploader/FileUploaderMulti";
|
||||
import { FileUploaderMono } from ":/components/Forms/FileUploader/FileUploaderMono";
|
||||
|
||||
export interface FileUploaderProps
|
||||
extends Omit<FieldProps, "state">,
|
||||
InputHTMLAttributes<HTMLInputElement> {
|
||||
state?: FieldState | "uploading" | undefined;
|
||||
multiple?: boolean;
|
||||
icon?: ReactElement;
|
||||
successIcon?: ReactElement;
|
||||
deleteIcon?: ReactElement;
|
||||
fileSelectedIcon?: ReactElement;
|
||||
uploadingIcon?: ReactElement;
|
||||
animateIcon?: boolean;
|
||||
name?: string;
|
||||
bigText?: string;
|
||||
onFilesChange?: (event: { target: { value: File[] } }) => void;
|
||||
// This is only here for storybook. It cannot be used in real conditions.
|
||||
fakeDefaultFiles?: File[];
|
||||
}
|
||||
|
||||
export interface FileUploaderRefType extends InputRefType {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const FileUploader = forwardRef<FileUploaderRefType, FileUploaderProps>(
|
||||
({ fullWidth, ...props }, ref) => {
|
||||
return (
|
||||
<Field fullWidth={fullWidth}>
|
||||
{props.multiple ? (
|
||||
<FileUploaderMulti {...props} ref={ref} />
|
||||
) : (
|
||||
<FileUploaderMono {...props} ref={ref} />
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
24
packages/react/src/components/Forms/FileUploader/tokens.ts
Normal file
24
packages/react/src/components/Forms/FileUploader/tokens.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { DefaultTokens } from "@openfun/cunningham-tokens";
|
||||
|
||||
export const tokens = (defaults: DefaultTokens) => ({
|
||||
"background-color": "white",
|
||||
"border-color": defaults.theme.colors["greyscale-300"],
|
||||
"border-radius": "0.5rem",
|
||||
"border-width": "1px",
|
||||
"border-style": "dotted",
|
||||
"background-color--active": defaults.theme.colors["primary-100"],
|
||||
color: defaults.theme.colors["greyscale-900"],
|
||||
padding: "1rem",
|
||||
"accent-color": defaults.theme.colors["primary-600"],
|
||||
"text-color": defaults.theme.colors["greyscale-600"],
|
||||
"text-size": "0.6875rem",
|
||||
"file-text-size": "0.8125rem",
|
||||
"file-text-color": defaults.theme.colors["greyscale-900"],
|
||||
"file-text-weight": defaults.theme.font.weights.light,
|
||||
"file-border-color": defaults.theme.colors["greyscale-300"],
|
||||
"file-border-width": "1px",
|
||||
"file-border-radius": "0.5rem",
|
||||
"file-background-color": "white",
|
||||
"file-specs-size": "0.6875rem",
|
||||
"file-specs-color": defaults.theme.colors["greyscale-600"],
|
||||
});
|
||||
24
packages/react/src/components/Forms/FileUploader/utils.ts
Normal file
24
packages/react/src/components/Forms/FileUploader/utils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* FROM https://gist.github.com/zentala/1e6f72438796d74531803cc3833c039c
|
||||
* @param bytes
|
||||
* @param decimals
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals: number = 2) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const exponent = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return (
|
||||
parseFloat((bytes / k ** exponent).toFixed(decimals)) +
|
||||
" " +
|
||||
units[exponent]
|
||||
);
|
||||
}
|
||||
|
||||
export const replaceInputFilters = (input: HTMLInputElement, files: File[]) => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
files.forEach((f) => {
|
||||
dataTransfer.items.add(f);
|
||||
});
|
||||
input.files = dataTransfer.files;
|
||||
};
|
||||
@@ -142,6 +142,26 @@
|
||||
--c--components--forms-input--color: #303C4B;
|
||||
--c--components--forms-input--label-color--focus: #0556BF;
|
||||
--c--components--forms-input--background-color: white;
|
||||
--c--components--forms-fileuploader--background-color: white;
|
||||
--c--components--forms-fileuploader--border-color: #E7E8EA;
|
||||
--c--components--forms-fileuploader--border-radius: 0.5rem;
|
||||
--c--components--forms-fileuploader--border-width: 1px;
|
||||
--c--components--forms-fileuploader--border-style: dotted;
|
||||
--c--components--forms-fileuploader--background-color--active: #EBF2FC;
|
||||
--c--components--forms-fileuploader--color: #0C1A2B;
|
||||
--c--components--forms-fileuploader--padding: 1rem;
|
||||
--c--components--forms-fileuploader--accent-color: #0556BF;
|
||||
--c--components--forms-fileuploader--text-color: #79818A;
|
||||
--c--components--forms-fileuploader--text-size: 0.6875rem;
|
||||
--c--components--forms-fileuploader--file-text-size: 0.8125rem;
|
||||
--c--components--forms-fileuploader--file-text-color: #0C1A2B;
|
||||
--c--components--forms-fileuploader--file-text-weight: 300;
|
||||
--c--components--forms-fileuploader--file-border-color: #E7E8EA;
|
||||
--c--components--forms-fileuploader--file-border-width: 1px;
|
||||
--c--components--forms-fileuploader--file-border-radius: 0.5rem;
|
||||
--c--components--forms-fileuploader--file-background-color: white;
|
||||
--c--components--forms-fileuploader--file-specs-size: 0.6875rem;
|
||||
--c--components--forms-fileuploader--file-specs-color: #79818A;
|
||||
--c--components--forms-field--width: 292px;
|
||||
--c--components--forms-field--font-size: 0.6875rem;
|
||||
--c--components--forms-field--color: #79818A;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@
|
||||
@import './components/DataGrid';
|
||||
@import './components/Forms/Checkbox';
|
||||
@import './components/Forms/Field';
|
||||
@import './components/Forms/FileUploader';
|
||||
@import './components/Forms/Radio';
|
||||
@import './components/Forms/Input';
|
||||
@import './components/Forms/LabelledBox';
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from "./components/DataGrid/SimpleDataGrid";
|
||||
export * from "./components/Forms/Checkbox";
|
||||
export * from "./components/DataGrid/DataList";
|
||||
export * from "./components/Forms/Field";
|
||||
export * from "./components/Forms/FileUploader";
|
||||
export * from "./components/Forms/Input";
|
||||
export * from "./components/Forms/Radio";
|
||||
export * from "./components/Forms/Select";
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
"clear_button_aria_label": "Clear selection",
|
||||
"clear_all_button_aria_label": "Clear all selections"
|
||||
},
|
||||
"file_uploader": {
|
||||
"delete_file_name": "Delete file {name}",
|
||||
"delete_file": "Delete file",
|
||||
"uploading": "Uploading...",
|
||||
"caption": "Drag and drop or ",
|
||||
"browse_files": "Browse files"
|
||||
},
|
||||
"date_picker": {
|
||||
"toggle_button_aria_label_open": "Open calendar",
|
||||
"toggle_button_aria_label_close": "Close calendar",
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
"clear_button_aria_label": "Effacer la sélection",
|
||||
"clear_all_button_aria_label": "Effacer toutes les sélections"
|
||||
},
|
||||
"file_uploader": {
|
||||
"delete_file_name": "Supprimer le fichier {name}",
|
||||
"delete_file": "Supprimer le fichier",
|
||||
"uploading": "Upload en cours ...",
|
||||
"caption": "Glisser-déposer ou ",
|
||||
"browse_files": "Parcourir"
|
||||
},
|
||||
"date_picker": {
|
||||
"toggle_button_aria_label_open": "Ouvrir le calendrier",
|
||||
"toggle_button_aria_label_close": "Fermer le calendrier",
|
||||
|
||||
Reference in New Issue
Block a user