Skip to content
Draft
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
224 changes: 224 additions & 0 deletions src/components/FileInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import {
useCallback,
useState,
} from 'react';
import {
_cs,
isDefined,
} from '@togglecorp/fujs';
import {
InputContainer,
type InputContainerProps,
useButtonFeatures,
} from '@togglecorp/toggle-ui';

import Header from '#components/Header';
import RawFileInput, { type RawFileInputProps } from '#components/RawFileInput';
import useDropHandler from '#hooks/useDropHandler';
import isValidFile, { ErrorType } from '#utils/common';

import styles from './styles.module.css';

type NameType = string | number | undefined;

type InheritedProps<T extends NameType> = (Omit<InputContainerProps, 'input'> & Omit<RawFileInputProps<T>, 'onChange' | 'value'>);
export type Props<T extends NameType> = InheritedProps<T> & {
inputElementRef?: React.RefObject<HTMLInputElement>;
inputClassName?: string;
labelClassName?: string;

status?: string;
maxFileSize?: number; // NOTE: maxFileSize is in MB.
} & ({
multiple: true;
value: File[] | undefined | null;
onChange?: (files: File[], name: T) => void;
} | {
multiple?: false;
value: File | undefined | null;
onChange?: (files: File | undefined, name: T) => void;
});

function FileInput<T extends NameType>(props: Props<T>) {
const {
className,
disabled,
error,
errorContainerClassName,
inputSectionClassName,
inputContainerClassName,
label,
labelContainerClassName,
readOnly,
uiMode,
inputElementRef,
containerRef,
inputSectionRef,
inputClassName,
value, // eslint-disable-line @typescript-eslint/no-unused-vars
onChange, // eslint-disable-line @typescript-eslint/no-unused-vars
name: nameFromProps,
multiple,
accept,
labelClassName,
children,
maxFileSize = 10, // 10MB is default max file size
...fileInputProps
} = props;

const [inputKey, setInputKey] = useState(0);
const [internalError, setInternalError] = useState<string>();

const handleFiles = useCallback(
(files: FileList | null) => {
setInternalError(undefined);
// eslint-disable-next-line react/destructuring-assignment
if (!files || !props.onChange) {
return;
}

const fileList = Array.from(files);
let numberOfFilesExceedSize = 0;
let numberOfInvalidFiles = 0;

const validFiles = fileList.filter((f) => {
const validity = isValidFile(f, maxFileSize, accept);
if (!validity.isValid) {
if (validity.errorType === ErrorType.invalidFileType) {
numberOfInvalidFiles += 1;
} else {
numberOfFilesExceedSize += 1;
}
}
return validity.isValid;
});

if (numberOfFilesExceedSize > 0 && numberOfInvalidFiles > 0) {
const isSingularFileSizeError = numberOfFilesExceedSize === 1;
const isSingularInvalidFileError = numberOfInvalidFiles === 1;
setInternalError(`${numberOfFilesExceedSize} ${isSingularFileSizeError ? 'file exceeds' : 'files exceed'} file size limit of ${maxFileSize} MB.
${numberOfFilesExceedSize} ${isSingularInvalidFileError ? 'file is' : 'files are'} invalid. They are removed from selection.`);
} else if (numberOfFilesExceedSize > 0) {
const isSingularError = numberOfFilesExceedSize === 1;
setInternalError(`${numberOfFilesExceedSize} ${isSingularError ? 'file exceeds' : 'files exceed'} file size limit of ${maxFileSize} MB.
${isSingularError ? 'It is' : 'They are'} removed from selection.`);
} else if (numberOfInvalidFiles > 0) {
const isSingularError = numberOfInvalidFiles === 1;
setInternalError(`${numberOfFilesExceedSize} ${isSingularError ? 'file is' : 'files are'} invalid.
${isSingularError ? 'It is' : 'They are'} removed from selection.`);
}

if (validFiles.length <= 0) {
return;
}

if (!multiple) {
const [firstFile] = validFiles;
// eslint-disable-next-line react/destructuring-assignment
const onChangeFromProps = props.onChange;
onChangeFromProps(firstFile, nameFromProps);
} else {
// eslint-disable-next-line react/destructuring-assignment
const onChangeFromProps = props.onChange;
onChangeFromProps(validFiles, nameFromProps);
}
},
// eslint-disable-next-line react/destructuring-assignment
[accept, multiple, props.onChange, nameFromProps, maxFileSize],
);

const handleChange = useCallback((
files: File[] | undefined,
) => {
if (isDefined(files)) {
handleFiles(files as unknown as FileList);
setInputKey((val) => val + 1);
}
}, [handleFiles]);

const handleDrop: React.DragEventHandler<HTMLDivElement> = useCallback((e) => {
e.preventDefault();
handleFiles(e.dataTransfer.files);
e.dataTransfer.clearData();
}, [handleFiles]);

const {
dropping,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
} = useDropHandler(handleDrop);

const {
className: buttonLabelClassName,
children: buttonLabelChildren,
} = useButtonFeatures({
className: labelClassName,
disabled,
variant: 'primary',
children: (
<>
{children}
<RawFileInput<T>
key={inputKey}
className={styles.input}
inputRef={inputElementRef}
readOnly={readOnly}
uiMode={uiMode}
disabled={disabled}
value={undefined}
name={nameFromProps}
onChange={handleChange}

Check failure on line 172 in src/components/FileInput/index.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

Type '(files: File[] | undefined) => void' is not assignable to type '(FormEventHandler<HTMLButtonElement> & ((files: File[] | undefined, name: T) => void)) | (FormEventHandler<HTMLButtonElement> & ((files: File | undefined, name: T) => void))'.
multiple={multiple}
accept={accept}
{...fileInputProps} // eslint-disable-line react/jsx-props-no-spreading
/>
</>
),
});

return (
<InputContainer
className={_cs(styles.inputContainer, className)}
containerRef={containerRef}
inputContainerClassName={_cs(inputContainerClassName)}
inputSectionClassName={_cs(styles.inputSection, inputSectionClassName)}
labelContainerClassName={_cs(styles.label, labelContainerClassName)}
errorContainerClassName={errorContainerClassName}
inputSectionRef={inputSectionRef}
disabled={disabled}
error={error ?? internalError}
label={label}
readOnly={readOnly}
input={(
<div
className={_cs(
inputClassName,
styles.inputChildren,
dropping && styles.draggedOver,
)}
onDrop={onDrop}
onDragOver={onDragOver}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
>
<Header
childrenContainerClassName={styles.headerChildren}
headingLevel={6}
heading="Choose a file or drag & drop it here"
headingDescription="Format: PDF, XLSX, DOCX"
/>
<div className={styles.browseButton}>
<label className={buttonLabelClassName}>

Check failure on line 213 in src/components/FileInput/index.tsx

View workflow job for this annotation

GitHub Actions / Lint JS

A form label must be associated with a control
{buttonLabelChildren}
</label>
Upto 5 MB
</div>
</div>
)}
/>
);
}

export default FileInput;
31 changes: 31 additions & 0 deletions src/components/FileInput/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.input-container {
.label {
color: var(--cms-ui-color-gray-500);
font-size: var(--cms-ui-font-size-xl);
}

.input-section {
border: 2px dashed var(--cms-ui-color-gray-50);
background-color: var(--cms-ui-color-gray-20);

.input-children {
display: flex;
align-items: center;
flex-direction: column;
gap: var(--cms-ui-spacing-lg);

.header-children {
align-items: center;
}

.browse-button {
display: flex;
align-items: center;
flex-direction: column;
}
}
.input {
visibility: hidden;
}
}
}
2 changes: 1 addition & 1 deletion src/components/Heading/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@
}

&.level-six {
--font-size: var(--cms-ui-font-size-lg);
--font-size: var(--cms-ui-font-size-md);
}
}
89 changes: 89 additions & 0 deletions src/components/RawFileInput/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
useCallback,
useState,
} from 'react';
import {
_cs,
randomString,
} from '@togglecorp/fujs';
import type { ButtonProps } from '@togglecorp/toggle-ui';
import { useButtonFeatures } from '@togglecorp/toggle-ui';

import styles from './styles.module.css';

export type RawFileInputProps<NAME extends number | string | undefined> = ButtonProps<NAME> & {
accept?: string;
disabled?: boolean;
inputProps?: React.ComponentPropsWithoutRef<'input'>;
inputRef?: React.RefObject<HTMLInputElement>;
name: NAME;
readOnly?: boolean;
} & ({
multiple: true;
onChange: (files: File[] | undefined, name: NAME) => void;
} | {
multiple?: false;
onChange: (files: File | undefined, name: NAME) => void;
});

function RawFileInput<NAME extends number | string | undefined>(props: RawFileInputProps<NAME>) {
const {
accept,
disabled,
inputProps,
inputRef,
multiple,
name,
onChange,
readOnly,
...buttonFeatureProps
} = props;

const [inputId] = useState(randomString);

const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (multiple) {
const values = event.currentTarget.files
? Array.from(event.currentTarget.files) : undefined;
onChange(values, name);
} else {
onChange(event.currentTarget.files?.[0] ?? undefined, name);
}

if (event.currentTarget.value) {
event.currentTarget.value = ''; // eslint-disable-line no-param-reassign
}
}, [multiple, name, onChange]);

const {
children,
className,
} = useButtonFeatures({
...buttonFeatureProps,
disabled,
});

return (
<label
htmlFor={inputId}
className={_cs(styles.fileInput, className)}
>
{children}
<input
id={inputId}
className={styles.input}
type="file"
accept={accept}
multiple={multiple}
onChange={handleChange}
name={typeof name === 'string' ? name : undefined}
ref={inputRef}
disabled={disabled}
readOnly={readOnly}
{...inputProps} // eslint-disable-line react/jsx-props-no-spreading
/>
</label>
);
}

export default RawFileInput;
7 changes: 7 additions & 0 deletions src/components/RawFileInput/styles.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.file-input {
.input {
visibility: hidden;
width: 0;
height: 0;
}
}
Loading
Loading