Skip to content

Commit

Permalink
feat: add file uploader component
Browse files Browse the repository at this point in the history
  • Loading branch information
abelflopes committed Jul 3, 2024
1 parent df887b2 commit a2e5fde
Show file tree
Hide file tree
Showing 7 changed files with 368 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import styles from "./styles/index.module.scss";
import React, { useMemo, useRef } from "react";
import classNames from "classnames";
import { Text } from "@react-ck/text";
import { Button, type ButtonProps } from "@react-ck/button";

// TODO: check https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file
// TODO: add size limitation: https://stackoverflow.com/questions/5697605/limit-the-size-of-a-file-upload-html-input-element
// TODO: max number of files
// TODO: size display helper
// TODO: util convert url to file https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
// TODO: util file size description
// TODO: add overlay loader
// TODO: add uploaded files feedback

export interface FileUploaderProps extends React.HTMLAttributes<HTMLElement> {
skin?: "default" | "negative" | "disabled";
variation?: "default" | "square";
icon?: React.ReactNode;
cta?: React.ReactNode;
description?: React.ReactNode;
inputProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, "type">;
buttonProps?: ButtonProps;
/** The validation message text */
validationMessage?: React.ReactNode;
}

// eslint-disable-next-line complexity -- TODO: fix
export const FileUploader = ({
skin = "default",
variation = "default",
icon,
cta,
description,
validationMessage,
className,
children,
inputProps,
buttonProps,
...otherProps
}: Readonly<FileUploaderProps>): React.ReactElement => {
const inputRef = useRef<HTMLInputElement>(null);

const isIconOnly = useMemo(() => Boolean(icon) && !cta && !description, [cta, description, icon]);

const onEnterPress = (e: React.KeyboardEvent<HTMLElement>): void => {
if (e.code === "Enter") inputRef.current?.click();
};

return (
<div
{...otherProps}
className={classNames(
variation !== "default" && styles[variation],
skin !== "default" && styles[skin],
!isIconOnly && styles.root,
isIconOnly && styles.root_icon_only,
className,
)}>
<input
{...inputProps}
ref={inputRef}
type="file"
className={classNames(styles.file, inputProps?.className)}
onKeyUp={onEnterPress}
/>
{!isIconOnly && icon}
{children ? <div className={styles.content}>{children}</div> : null}
{isIconOnly || cta ? (
<Button
{...buttonProps}
icon={isIconOnly ? icon : null}
disabled={skin === "disabled" || buttonProps?.disabled}
className={classNames(styles.button, buttonProps?.className)}
onKeyUp={(e) => {
onEnterPress(e);
buttonProps?.onKeyUp?.(e);
}}
onClick={(e) => {
inputRef.current?.click();
buttonProps?.onClick?.(e);
}}>
{cta}
</Button>
) : null}

{description || validationMessage ? (
<div className={styles.details}>
{description ? <Text variation="small">{description}</Text> : null}
{validationMessage ? (
<Text variation="small" className={styles.validation_message}>
{validationMessage}
</Text>
) : null}
</div>
) : null}
</div>
);
};
3 changes: 3 additions & 0 deletions packages/components/_provisional/src/file-uploader/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./FileUploader";

export * from "./utils/read-file";
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
@use "@react-ck/theme";
@use "@react-ck/text";

$bg: theme.get-color(neutral-light-1-5);

.root {
position: relative;
background: $bg;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: theme.get-spacing(2);
padding: theme.get-spacing(4);
border-radius: theme.get-spacing(1);
box-sizing: border-box;
outline: solid theme.get-css-var(spacing, border) transparent;
transition-property: outline, outline-offset;
transition-duration: 0.3s;
transition-timing-function: ease;
border: solid theme.get-css-var(spacing, border) $bg;

&:not(.disabled):hover,
&:not(.disabled):focus-within {
outline: dashed theme.get-css-var(spacing, border) theme.get-color(highlight-primary);
outline-offset: theme.get-spacing(0.5);
}
}

.root_icon_only {
display: inline-flex;
}

// Skins

.negative {
border: solid theme.get-css-var(spacing, border) theme.get-color(status-negative);
}

.disabled {
cursor: not-allowed;
filter: grayscale(1);
opacity: 0.4;
}

// Variations

.square {
min-height: theme.get-spacing(20);
min-width: theme.get-spacing(20);
aspect-ratio: 1 / 1;
max-width: 100%;
max-height: 100%;
}

.content {
@include text.text-base;

text-align: center;
}

.file {
opacity: 0;
position: absolute;
height: 100%;
width: 100%;
z-index: 1;
cursor: pointer;
}

.disabled .file {
cursor: not-allowed;
}

.button {
z-index: 2;
}

.validation_message {
color: theme.get-color(status-negative);
}

.details {
text-align: center;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export interface FileReadResult extends Pick<File, "name" | "lastModified" | "size" | "type"> {
content: NonNullable<ProgressEvent<FileReader>["target"]>["result"];
}

export type ProgressFunction<T = number> = (data: T) => void;

/**
* Returns file info and encoded content
*/
export const readFile = async (
file: File,
onProgress?: ProgressFunction,
): Promise<FileReadResult> =>
new Promise<FileReadResult>((resolve, reject) => {
const reader = new FileReader();

function trackProgress(e: ProgressEvent<FileReader>): void {
const percent = e.loaded === 0 && e.total === 0 ? 100 : (e.loaded * 100) / e.total;
onProgress?.(percent);
}

function handleError(e: ProgressEvent<FileReader>): void {
const error = e.target?.error;
reject(new Error(error ? `${error.name} - ${error.message}` : "Unknown Error"));
}

reader.addEventListener("loadstart", trackProgress);
reader.addEventListener("progress", trackProgress);

reader.addEventListener("abort", handleError);
reader.addEventListener("error", handleError);

reader.addEventListener("load", (e) => {
if (!e.target) throw new Error("Missing file reader target");

resolve({
name: file.name,
lastModified: file.lastModified,
size: file.size,
type: file.type,
content: e.target.result,
});
});

reader.readAsDataURL(file);
});

export const readFileList = async (
fileList: FileList,
onProgress?: ProgressFunction<{
loadedFiles: number;
totalFiles: number;
totalProgress: number;
}>,
): Promise<FileReadResult[]> => {
const progressMap: Record<string, number> = {};

function getStats(): void {
const progressValues = Object.values(progressMap);
const totalFiles = progressValues.length;
const loadedFiles = progressValues.filter((i) => i === 100).length;
const totalProgress =
progressValues.reduce((prev, curr) => curr + prev) / progressValues.length;

onProgress?.({ totalFiles, loadedFiles, totalProgress });
}

const result = await Promise.all(
[...fileList].map(
async (i): Promise<FileReadResult> =>
readFile(i, (progress) => {
const id = [i.name, i.lastModified, i.size, i.type].join();
progressMap[id] = progress;
getStats();
}),
),
);
return result;
};
Empty file.
2 changes: 2 additions & 0 deletions packages/components/_provisional/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ export * from "./tooltip";
export * from "./popover";

export * from "./chat";

export * from "./file-uploader";
100 changes: 100 additions & 0 deletions packages/docs/stories/src/file-uploader.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from "react";
import { type Meta, type StoryObj } from "@storybook/react";
import { Manager } from "@react-ck/manager/src";
import { configureStory } from "@react-ck/story-config";
import { FileUploader, type FileUploaderProps, readFileList } from "@react-ck/provisional/src";
import { Icon } from "@react-ck/icon/src";
import { IconUploadOutline } from "@react-ck/icon/icons/IconUploadOutline";
import { IconAttach } from "@react-ck/icon/icons/IconAttach";

type Story = StoryObj<typeof FileUploader>;

const meta: Meta<typeof FileUploader> = {
title: "Form/File uploader",
...configureStory(FileUploader, {
parameters: {
layout: "padded",
},
decorators: [
(Story): React.ReactElement => (
<Manager>
<Story />
</Manager>
),
],
}),
};

const args: FileUploaderProps = {
icon: (
<Icon size="l">
<IconUploadOutline />
</Icon>
),
cta: "Browse device",
description: "Max file size: 5MB",
children: "Drag & drop files here to upload",
inputProps: {
accept: ".pdf,.jpb,.png,video/*",
multiple: true,
onChange: (e) => {
void (async (): Promise<void> => {
const files = e.target.files && (await readFileList(e.target.files));
console.log("Done", files);
})();
},
},
};

export default meta;

export const Default: Story = {
args,
};

export const Disabled: Story = {
args: {
...args,
tabIndex: -1,
skin: "disabled",
inputProps: {
...args.inputProps,
disabled: true,
},
},
};

export const Error: Story = {
args: {
...args,
skin: "negative",
validationMessage: "Error: file is too big",
},
};

export const Square: Story = {
parameters: {
layout: "centered",
},
args: {
...args,
variation: "square",
},
};

export const AsIconButton: Story = {
parameters: {
layout: "centered",
},
args: {
inputProps: args.inputProps,
buttonProps: {
skin: "secondary",
},
icon: (
<Icon>
<IconAttach />
</Icon>
),
},
};

0 comments on commit a2e5fde

Please sign in to comment.