Skip to content

Commit

Permalink
Merge pull request #9 from near/feat/file-input
Browse files Browse the repository at this point in the history
File Input (1 of 2)
  • Loading branch information
calebjacob authored Aug 7, 2024
2 parents 480cdb9 + 5b9e4ee commit a603540
Show file tree
Hide file tree
Showing 6 changed files with 320 additions and 1 deletion.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@near-pagoda/ui",
"version": "0.2.6",
"version": "0.2.7",
"description": "A React component library that implements the official NEAR design system.",
"license": "MIT",
"repository": {
Expand Down
56 changes: 56 additions & 0 deletions src/components/FileInput.README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# FileInput

This component uses a native `<input type="file" />` tag underneath the hood. The [accept](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept) and [multiple](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/multiple) props are passed through to the input tag and behave as they would natively. We've included client side validation to make sure the `accept` and `multiple` props are respected for both selected and dropped files.

Additionally, you can pass a value for `maxFileSizeBytes` to limit the max size of each file. The default is unlimited (undefined).

```tsx
import { FileInput } from '@near-pagoda/ui';

...

const [error, setError] = useState("");
const [disabled, setDisabled] = useState(false);

...

<FileInput
label="Artwork"
name="artwork"
accept="image/*"
error={error}
disabled={disabled}
maxFileSizeBytes={1_000_000}
multiple
onChange={(files) => console.log(files)}
/>
```

## React Hook Form

```tsx
import { FileInput } from '@near-pagoda/ui';
import { Controller, useForm } from 'react-hook-form';

type FormSchema = {
artwork: File[];
};

...


const form = useForm<FormSchema>();

...

<Controller
control={form.control}
name="artwork"
rules={{
required: 'Please select an image',
}}
render={({ field, fieldState }) => (
<FileInput label="Event Artwork" accept="image/*" error={fieldState.error?.message} {...field} />
)}
/>
```
92 changes: 92 additions & 0 deletions src/components/FileInput.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
.wrapper {
display: flex;
width: 100%;
flex-direction: column;
gap: 5px;

&:focus-within .input {
border-style: solid;
border-color: var(--violet8);
}
}

.label {
display: block;
font: var(--text-xs);
font-weight: 600;
color: var(--sand12);
}

.nativeInput {
opacity: 0;
position: absolute;
width: 1px;
height: 1px;
}

.input {
display: flex;
align-items: stretch;
flex-direction: column;
gap: var(--gap-m);
padding: var(--gap-m);
border-radius: 6px;
background-color: var(--white);
border: 2px dashed var(--sand6);
outline: none;
transition: all var(--transition-speed);
cursor: pointer;

[data-disabled='true'] & {
pointer-events: none;
opacity: 0.5;
}

[data-dragging='true'] & {
border-color: var(--violet8);
}

[data-error='true'] & {
border-color: var(--red9);
}

&:hover {
border-style: solid;
background: var(--sand2);
}
}

.files {
display: flex;
align-items: stretch;
flex-direction: column;
gap: var(--gap-m);
}

.file {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding-bottom: var(--gap-m);
border-bottom: 1px solid var(--sand6);

img {
display: block;
max-width: 100%;
border-radius: 4px;
}
}

.filename {
width: 100%;
display: flex;
align-items: center;
gap: var(--gap-s);
}

.cta {
display: flex;
align-items: center;
flex-direction: column;
gap: var(--gap-s);
}
158 changes: 158 additions & 0 deletions src/components/FileInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { FileArrowUp, Paperclip } from '@phosphor-icons/react';
import type { ChangeEventHandler, CSSProperties, DragEventHandler, FocusEventHandler } from 'react';
import { forwardRef, useState } from 'react';

import { useDebouncedValue } from '../hooks/debounce';
import { formatBytes } from '../utils/number';
import { AssistiveText } from './AssistiveText';
import s from './FileInput.module.scss';
import { SvgIcon } from './SvgIcon';
import { Text } from './Text';
import { openToast } from './Toast';

type Props = {
accept: '*' | 'image/*' | 'audio/*' | 'video/*' | (string & {}); // https://stackoverflow.com/a/61048124
className?: string;
disabled?: boolean;
error?: string;
label?: string;
maxFileSizeBytes?: number;
multiple?: boolean;
name: string;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange: (value: File[] | null) => any;
style?: CSSProperties;
value?: File[] | null | undefined;
};

export const FileInput = forwardRef<HTMLInputElement, Props>(
(
{ accept, className = '', disabled, label, error, maxFileSizeBytes, multiple, name, style, value, ...props },
ref,
) => {
const [isDragging, setIsDragging] = useState(false);
const isDraggingDebounced = useDebouncedValue(isDragging, 50);
const assistiveTextId = `${name}-assistive-text`;

const handleFileListChange = (fileList: FileList | null) => {
let files = [...(fileList ?? [])];
if (!multiple) {
files = files.slice(0, 1);
}

const accepted = accept.split(',').map((type) => type.trim());
const errors: string[] = [];

const validFiles = files.filter((file) => {
const fileTypeCategory = file.type.split('/').shift()!;
const fileExtension = file.name.split('.').pop()!;

const fileTypeIsValid =
accepted.includes('*') ||
accepted.includes(`${fileTypeCategory}/*`) ||
accepted.includes(file.type) ||
accepted.includes(`.${fileExtension}`);

const fileSizeIsValid = !maxFileSizeBytes || file.size <= maxFileSizeBytes;

if (!fileTypeIsValid) {
errors.push(`File type not allowed: ${file.type}. Allowed file types: ${accept}`);
return false;
}

if (!fileSizeIsValid) {
errors.push(`File size exceeds maximum of: ${formatBytes(maxFileSizeBytes)}`);
return false;
}

return true;
});

errors.forEach((error) => {
openToast({
type: 'error',
title: 'Invalid File',
description: error,
});
});

props.onChange(validFiles.length > 0 ? validFiles : null);
};

const onChange: ChangeEventHandler<HTMLInputElement> = (event) => {
handleFileListChange(event.target.files);
};

const onDragLeave: DragEventHandler<HTMLLabelElement> = () => {
setIsDragging(false);
};

const onDragOver: DragEventHandler<HTMLLabelElement> = (event) => {
event.preventDefault();
setIsDragging(true);
};

const onDrop: DragEventHandler<HTMLLabelElement> = async (event) => {
event.preventDefault();
setIsDragging(false);
handleFileListChange(event.dataTransfer.files);
};

return (
<label
className={`${s.wrapper} ${className}`}
style={style}
data-dragging={isDraggingDebounced}
data-disabled={disabled}
data-error={!!error}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
<input
type="file"
className={s.nativeInput}
aria-errormessage={error ? assistiveTextId : undefined}
aria-invalid={!!error}
accept={accept}
multiple={multiple}
ref={ref}
name={name}
disabled={disabled}
{...props}
onChange={onChange}
/>

{label && <span className={s.label}>{label}</span>}

<div className={s.input}>
{value && value.length > 0 && (
<div className={s.files}>
{value.map((file) => (
<div className={s.file} key={file.name}>
{file.type.includes('image/') && <img src={URL.createObjectURL(file)} alt={file.name} />}

<div className={s.filename}>
<SvgIcon icon={<Paperclip />} size="xs" color="sand10" />
<Text size="text-xs">{file.name}</Text>
</div>
</div>
))}
</div>
)}

<div className={s.cta}>
<SvgIcon icon={<FileArrowUp />} color="violet8" />
<Text size="text-s" color="sand12">
Select or drag & drop {multiple ? 'files' : 'file'}
</Text>
</div>
</div>

<AssistiveText variant="error" message={error} id={assistiveTextId} />
</label>
);
},
);

FileInput.displayName = 'FileInput';
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './components/Combobox';
export * from './components/Container';
export * as Dialog from './components/Dialog';
export * as Dropdown from './components/Dropdown';
export * from './components/FileInput';
export * from './components/Flex';
export * from './components/Form';
export * from './components/Grid';
Expand Down
12 changes: 12 additions & 0 deletions src/utils/number.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ export function formatDollar(number: string | number | null | undefined) {

return dollarFormatter.format(parsedNumber);
}

export function formatBytes(bytes: number | null | undefined, decimals = 1) {
if (!bytes) return '0 Bytes';

const k = 1000;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];

const i = Math.floor(Math.log(bytes) / Math.log(k));

return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}

0 comments on commit a603540

Please sign in to comment.