diff --git a/package.json b/package.json index 61c9355..e04c15f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/components/FileInput.README.md b/src/components/FileInput.README.md new file mode 100644 index 0000000..d69dfe8 --- /dev/null +++ b/src/components/FileInput.README.md @@ -0,0 +1,56 @@ +# FileInput + +This component uses a native `` 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); + +... + + 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(); + +... + + ( + + )} +/> +``` diff --git a/src/components/FileInput.module.scss b/src/components/FileInput.module.scss new file mode 100644 index 0000000..caf9949 --- /dev/null +++ b/src/components/FileInput.module.scss @@ -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); +} diff --git a/src/components/FileInput.tsx b/src/components/FileInput.tsx new file mode 100644 index 0000000..c2a76ef --- /dev/null +++ b/src/components/FileInput.tsx @@ -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; + onChange: (value: File[] | null) => any; + style?: CSSProperties; + value?: File[] | null | undefined; +}; + +export const FileInput = forwardRef( + ( + { 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 = (event) => { + handleFileListChange(event.target.files); + }; + + const onDragLeave: DragEventHandler = () => { + setIsDragging(false); + }; + + const onDragOver: DragEventHandler = (event) => { + event.preventDefault(); + setIsDragging(true); + }; + + const onDrop: DragEventHandler = async (event) => { + event.preventDefault(); + setIsDragging(false); + handleFileListChange(event.dataTransfer.files); + }; + + return ( + + ); + }, +); + +FileInput.displayName = 'FileInput'; diff --git a/src/index.ts b/src/index.ts index 567232d..8f4744c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/utils/number.ts b/src/utils/number.ts index 73d8227..2be1162 100644 --- a/src/utils/number.ts +++ b/src/utils/number.ts @@ -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]}`; +}