UNPKG

@react-md/form

Version:

This package is for creating all the different form input types.

682 lines (620 loc) 19.6 kB
import type { ChangeEventHandler, DragEventHandler } from "react"; import { nanoid } from "nanoid"; /** @remarks \@since 2.9.0 */ export interface BaseFileUploadStats { /** * A unique key associated with each upload generated by `nanoid`. */ key: string; /** * The file instance that is being uploaded. */ file: File; /** * The current upload progress as a percentage from 0 - 100 percent. */ progress: number; } /** @remarks \@since 2.9.0 */ export interface ProcessingFileUploadStats extends BaseFileUploadStats { status: "pending" | "uploading"; } /** @remarks \@since 2.9.0 */ export type FileReaderResult = FileReader["result"]; /** @remarks \@since 2.9.0 */ export interface CompletedFileUploadStats extends BaseFileUploadStats { status: "complete"; /** * The result after a `FileReader` has read a file completely. * * Note: This _should_ be an `ArrayBuffer` if the next step is to upload to a * server. * * @see {@link FileReaderParser} * @see {@link getFileParser} * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/FileReader | FileReader} */ result: FileReaderResult; } /** @remarks \@since 2.9.0 */ export type FileUploadStats = | ProcessingFileUploadStats | CompletedFileUploadStats; /** @remarks \@since 2.9.0 */ export type FileUploadStatus = FileUploadStats["status"]; /** @remarks \@since 2.9.0 */ export interface FileUploadHandlers<E extends HTMLElement> { onDrop?: DragEventHandler<E>; onChange?: ChangeEventHandler<HTMLInputElement>; } /** * An error that will be created if a user tries dragging and dropping files * from a shared directory that they do not have access to. This error will not * occur much. * * @remarks \@since 2.9.0 */ export class FileAccessError extends Error { /** * A unique key generated by `nanoid` that can be used as a `React` key */ public key: string; /** * * @param message - An optional message for the error. */ constructor(message?: string) { super(message); this.key = nanoid(); this.name = "FileAccessError"; } } /** * An error that just requires a `File` to be passed as the first argument. * * @remarks \@since 2.9.0 */ export class GenericFileError extends Error { /** * A unique key generated by `nanoid` that can be used as a `React` key */ public key: string; /** * * @param files - A list of files that caused the error. * @param reason - An optional reason for the error */ constructor(public files: readonly File[], public reason?: string) { super("Invalid files"); this.key = nanoid(); this.name = "GenericFileError"; } } /** * An error that is created during the upload process if the number of files * exceeds the {@link FileUploadOptions.maxFiles} amount. * * @remarks \@since 2.9.0 */ export class TooManyFilesError extends GenericFileError { /** * * @param files - The list of files that could not be uploaded due to the file * limit defined. * @param limit - The max limit of files allowed. */ constructor(files: readonly File[], public limit: number) { super(files, "file limit"); this.name = "TooManyFilesError"; } } /** * An error that will be created if a user tries to upload a file that * is either: * - less than the {@link FileValidationOptions.minFileSize} * - greater than the {@link FileValidationOptions.maxFileSize} * - including the file would be greater than the {@link FileValidationOptions.totalFileSize} * * @remarks \@since 2.9.0 */ export class FileSizeError extends GenericFileError { /** * * @param files - The list of files that have the file size error * @param type - The file size error type * @param limit - The number of bytes allowed based on the type */ constructor( files: readonly File[], public type: "min" | "max" | "total", public limit: number ) { super(files, "file size"); this.name = "FileSizeError"; } } /** * An error that will be created if a user tries to upload a file that does not * end with one of the {@link FileValidationOptions.extensions}. * * @remarks \@since 2.9.0 */ export class FileExtensionError extends GenericFileError { /** * * @param files - The file that caused the error * @param extensions - The allowed list of file extensions */ constructor(files: readonly File[], public extensions: readonly string[]) { super(files, "extension"); this.name = "FileExtensionError"; } } /** * Mostly an internal type that is used to allow custom validation errors * * @remarks \@since 2.9.0 */ export type FileValidationError<E = GenericFileError> = | FileAccessError | TooManyFilesError | FileSizeError | FileExtensionError | E; /** * A simple type-guard that can be used to check if the * {@link FileValidationError} is the {@link GenericFileError} which can be * useful when displaying the errors to the user. * * @param error - The error to check * @returns true if the error is a {@link FileAccessError} */ export function isGenericFileError<CustomError>( error: FileValidationError<CustomError> ): error is GenericFileError { return "name" in error && error.name === "GenericFileError"; } /** * A simple type-guard that can be used to check if the * {@link FileValidationError} is the {@link FileAccessError} which can be * useful when displaying the errors to the user. * * @param error - The error to check * @returns true if the error is a {@link FileAccessError} */ export function isFileAccessError<CustomError>( error: FileValidationError<CustomError> ): error is FileAccessError { return "name" in error && error.name === "FileAccessError"; } /** * A simple type-guard that can be used to check if the * {@link FileValidationError} is the {@link TooManyFilesError} which can be * useful when displaying the errors to the user. * * @param error - The error to check * @returns true if the error is a {@link TooManyFilesError} */ export function isTooManyFilesError<CustomError>( error: FileValidationError<CustomError> ): error is TooManyFilesError { return "name" in error && error.name === "TooManyFilesError"; } /** * A simple type-guard that can be used to check if the * {@link FileValidationError} is the {@link FileSizeError} which can be * useful when displaying the errors to the user. * * @param error - The error to check * @returns true if the error is a {@link FileSizeError} */ export function isFileSizeError<CustomError>( error: FileValidationError<CustomError> ): error is FileSizeError { return "name" in error && error.name === "FileSizeError"; } /** * A simple type-guard that can be used to check if the * {@link FileValidationError} is the {@link FileExtensionError} which can be * useful when displaying the errors to the user. * * @param error - The error to check * @returns true if the error is a {@link FileExtensionError} */ export function isFileExtensionError<CustomError>( error: FileValidationError<CustomError> ): error is FileExtensionError { return "name" in error && error.name === "FileExtensionError"; } /** * This function is used to determine if a file should be added to the * {@link FileExtensionError}. The default implementation should work for most * use cases except when files that do not have extensions can be uploaded. i.e. * LICENSE files. * * @param file - The file being checked * @param extensionRegExp - A regex that will only be defined if the * `extensions` list had at least one value. * @param extensions - The list of extensions allowed * @returns true if the file has a valid name. * @remarks \@since 3.1.0 */ export type IsValidFileName = ( file: File, extensionRegExp: RegExp | undefined, extensions: readonly string[] ) => boolean; /** * * @defaultValue `matcher?.test(file.name) ?? true` * @remarks \@since 3.1.0 */ export const isValidFileName: IsValidFileName = (file, matcher) => matcher?.test(file.name) ?? true; /** @remarks \@since 2.9.0 */ export interface FileValidationOptions { /** * If the number of files should be limited, set this value to a number * greater than `0`. * * Note: This still allows "infinite" files when set to `0` since the * `<input>` element should normally be set to `disabled` if files should not * be able to be uploaded. * * @defaultValue `-1` */ maxFiles?: number; /** * An optional minimum file size to enforce for each file. This will only be * used when it is greater than `0`. * * @defaultValue `-1` */ minFileSize?: number; /** * An optional maximum file size to enforce for each file. This will only be * used when it is greater than `0`. * * @defaultValue `-1` */ maxFileSize?: number; /** * An optional list of extensions to enforce when uploading files. * * Note: The extensions and file names will be compared ignoring case. * * @example * Only Allow Images * ```ts * const extensions = ["png", "jpeg", "jpg", "gif"]; * ``` */ extensions?: readonly string[]; /** {@inheritDoc IsValidFileName} */ isValidFileName?: IsValidFileName; /** * An optional total file size to enforce when the {@link maxFiles} option is * not set to `1`. * * @defaultValue `-1` */ totalFileSize?: number; } /** @remarks \@since 2.9.0 */ export interface FilesValidationOptions extends Required<FileValidationOptions> { /** * The total number of bytes in the {@link FileUploadHookReturnValue.stats} * list. This is really just: * * ```ts * const totalBytes = stats.reduce((total, stat) => total + stat.file.size, 0); * ``` */ totalBytes: number; /** * The total number of files in the {@link FileUploadHookReturnValue.stats}. */ totalFiles: number; } /** @remarks \@since 2.9.0 */ export interface ValidatedFilesResult<CustomError> { /** * A filtered list of files that have been validated and can be queued for the * upload process. */ pending: readonly File[]; /** * A list of {@link FileValidationError} that occurred during the validation * step. * * Note: If an error has occurred, the file **should not** be added to the * {@link pending} list of files. */ errors: readonly FileValidationError<CustomError>[]; } /** * This function will be called whenever a file has been uploaded by the user * either through an `<input type="file">` or drag and drop behavior. * * @example * Simple Example * ```ts * const validateFiles: FilesValidator = (files, options) => { * const invalid: File[] = []; * const pending: File[] = []; * for (const file of files) { * if (!/\.(jpe?g|svg|png)$/i.test(name)) { * invalid.push(file); * } else { * pending.push(file); * } * } * * const errors: FileValidationError[] = []; * if (invalid.length) { * errors.push(new GenericFileError(invalid)) * } * * return { pending, errors }; * }; * ``` * * @typeparam E - An optional custom file validation error. * @param files - The list of files to check * @param options - The {@link FilesValidationOptions} * @returns the {@link ValidatedFilesResult} * @see {@link validateFiles} for the default implementation * @remarks \@since 2.9.0 */ export type FilesValidator<CustomError = never> = ( files: readonly File[], options: FilesValidationOptions ) => ValidatedFilesResult<CustomError>; /** * A pretty decent default implementation for validating files with the * {@link useFileUpload} that ensures the {@link FilesValidationOptions} are * enforced before allowing a file to be uploaded. * * @typeparam E - An optional custom file validation error. * @param files - The list of files to check * @param options - The {@link FilesValidationOptions} * @returns the {@link ValidatedFilesResult} * @remarks \@since 2.9.0 */ export function validateFiles<CustomError>( files: readonly File[], { maxFiles, extensions, minFileSize, maxFileSize, totalBytes, totalFiles, totalFileSize, isValidFileName, }: FilesValidationOptions ): ValidatedFilesResult<CustomError> { const errors: FileValidationError<CustomError>[] = []; const pending: File[] = []; const extraFiles: File[] = []; const extensionRegExp = extensions.length > 0 ? new RegExp(`\\.(${extensions.join("|")})$`, "i") : undefined; let maxFilesReached = maxFiles > 0 && totalFiles >= maxFiles; let remainingBytes = totalFileSize - totalBytes; const extensionErrors: File[] = []; const minErrors: File[] = []; const maxErrors: File[] = []; const totalSizeErrors: File[] = []; for (let i = 0; i < files.length; i += 1) { const file = files[i]; let valid = true; const { size } = file; if (!isValidFileName(file, extensionRegExp, extensions)) { valid = false; extensionErrors.push(file); } if (minFileSize > 0 && size < minFileSize) { valid = false; minErrors.push(file); } if (maxFileSize > 0 && size > maxFileSize) { valid = false; maxErrors.push(file); } else if (totalFileSize > 0 && remainingBytes - file.size < 0) { // don't want both errors displaying valid = false; totalSizeErrors.push(file); } if (maxFilesReached && valid) { extraFiles.push(file); } else if (!maxFilesReached && valid) { pending.push(file); remainingBytes -= file.size; maxFilesReached = maxFilesReached || (maxFiles > 0 && totalFiles + pending.length >= maxFiles); } } if (extensionErrors.length) { errors.push(new FileExtensionError(extensionErrors, extensions)); } if (minErrors.length) { errors.push(new FileSizeError(minErrors, "min", minFileSize)); } if (maxErrors.length) { errors.push(new FileSizeError(maxErrors, "max", maxFileSize)); } if (totalSizeErrors.length) { errors.push(new FileSizeError(totalSizeErrors, "total", totalFileSize)); } if (extraFiles.length) { errors.push(new TooManyFilesError(extraFiles, maxFiles)); } return { pending, errors }; } /** * This will first check if the mime-type of the file starts with `text/` and * fallback to checking a few file names or extensions that should be considered * text. * * This function is not guaranteed to be 100% correct and is only useful if * trying to generate a preview of files uploaded to the browser. * * @param file - The file to check * @returns `true` if the file should be considered as a text-content file. * @remarks \@since 2.9.0 */ export function isTextFile(file: File): boolean { return /\.((j|t)sx?|json|lock|hbs|ya?ml|log|txt|md)$/i.test(file.name); } /** * This will first check if the mime-type of the file starts with `text\/` and * fallback to checking a few file names or extensions that should be considered * text. * * This function is not guaranteed to be 100% correct and is only useful if * trying to generate a preview of files uploaded to the browser. * * @param file - The file to check * @returns `true` if the file should be considered as a text content file. * @remarks \@since 2.9.0 */ export function isImageFile(file: File): boolean { return /\.(a?png|avif|svg|tiff|gifv?|jpe?g)/i.test(file.name); } /** * This will first check if the mime-type of the file starts with `audio/` and * fallback to checking a few file names or extensions that should be considered * audio. * * This function is not guaranteed to be 100% correct and is only useful if * trying to generate a preview of files uploaded to the browser. * * @param file - The file to check * @returns `true` if the file should be considered as a audio content file. * @remarks \@since 2.9.0 */ export function isAudioFile(file: File): boolean { return /\.(mp3|wav|ogg|m4p|flac)$/i.test(file.name); } /** * This will first check if the mime-type of the file starts with `video/` and * fallback to checking a few file names or extensions that should be considered * video. * * This function is not guaranteed to be 100% correct and is only useful if * trying to generate a preview of files uploaded to the browser. * * @param file - The file to check * @returns `true` if the file should be considered as a video content file. * @remarks \@since 2.9.0 */ export function isVideoFile(file: File): boolean { return /\.(mkv|mpe?g|mov|avi|flv|webm|mp4)$/i.test(file.name); } /** * This function is not guaranteed to be 100% correct and is only useful if * trying to generate a preview of files uploaded to the browser. * * @param file - The file to check * @returns `true` if the file matches an image, audio, or video file. * @remarks \@since 2.9.0 */ export function isMediaFile(file: File): boolean { return isImageFile(file) || isAudioFile(file) || isVideoFile(file); } /** * One of the function names from a `FileReader` to upload a file to the * client. * * Note: If this file does not need to be previewed in the browser and will * immediately be uploaded to a server, use `readAsArrayBuffer`. * * @remarks \@since 2.9.0 */ export type FileReaderParser = | "readAsText" | "readAsDataURL" | "readAsBinaryString" | "readAsArrayBuffer"; /** * A function that should return one of the {@link FileReaderParser} functions * to start uploading a file to the browser. * * @example * The Default File Upload Parser * ```ts * export const getFileParser: GetFileParser = (file) => { * if (isMediaFile(file)) { * return "readAsDataURL"; * } * * if (isTextFile(file)) { * return "readAsText"; * } * * return "readAsArrayBuffer"; * }; * ``` * * @param file - The file to get a parser for * @returns the {@link FileReaderParser} string. * @remarks \@since 2.9.0 */ export type GetFileParser = (file: File) => FileReaderParser; /** * This function will attempt to read: * - media (image, audio, and video) files as a data url so they can be * previewed in `<img>`, `<audio>`, and `<video>` tags * - text files as plain text * - everything else as an `ArrayBuffer` which can be manually converted into a * data url if needed with `URL.createObjectURL` * * @remarks \@since 2.9.0 */ export const getFileParser: GetFileParser = (file) => { if (isMediaFile(file)) { return "readAsDataURL"; } if (isTextFile(file)) { return "readAsText"; } return "readAsArrayBuffer"; }; /** @remarks \@since 2.9.0 */ export interface SplitFileUploads { readonly pending: readonly ProcessingFileUploadStats[]; readonly uploading: readonly ProcessingFileUploadStats[]; readonly complete: readonly CompletedFileUploadStats[]; } /** * This util will split all the current upload stats by status. * * @param stats - The {@link FileUploadStats} list generally returned by the * {@link useFileUpload} hook. * @returns the {@link SplitFileUploads}. * @remarks \@since 2.9.0 */ export function getSplitFileUploads( stats: readonly FileUploadStats[] ): SplitFileUploads { const pending: ProcessingFileUploadStats[] = []; const uploading: ProcessingFileUploadStats[] = []; const complete: CompletedFileUploadStats[] = []; stats.forEach((stat) => { if (stat.status === "pending") { pending.push(stat); } else if (stat.status === "uploading") { uploading.push(stat); } else if (stat.status === "complete") { complete.push(stat); } else { /* istanbul ignore next */ throw new Error("Invalid upload stat"); } }); return { pending, uploading, complete }; }