diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e76f28a3..5ac4f86f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Implement `InputFileAvatar` component + ## [1.0.13] - 2024-02-14 ### Added diff --git a/package.json b/package.json index d99ceadac..706230211 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "classnames": "^2.0.0", "react": "^18.0.0", "react-dom": "^18.0.0", + "react-dropzone": "^14.2.0", "react-imask": "^7.4.0" }, "peerDependencies": { diff --git a/src/components/input/index.ts b/src/components/input/index.ts index 3243434e0..63f8e3e07 100644 --- a/src/components/input/index.ts +++ b/src/components/input/index.ts @@ -1,5 +1,6 @@ export * from './inputContainer'; export * from './inputDate'; +export * from './inputFileAvatar'; export * from './inputNumber'; export * from './inputNumberMax'; export * from './inputSearch'; diff --git a/src/components/input/inputContainer/inputContainer.api.ts b/src/components/input/inputContainer/inputContainer.api.ts index df2909062..199a194cf 100644 --- a/src/components/input/inputContainer/inputContainer.api.ts +++ b/src/components/input/inputContainer/inputContainer.api.ts @@ -64,6 +64,10 @@ export interface IInputContainerBaseProps { * Classes for the input wrapper. */ wrapperClassName?: string; + /** + * Shortcircuits all the input wrapper classes to pass control to the child component. + */ + useCustomWrapper?: boolean; } export interface IInputContainerProps extends IInputContainerBaseProps, Omit, 'id'> {} diff --git a/src/components/input/inputContainer/inputContainer.tsx b/src/components/input/inputContainer/inputContainer.tsx index 258031254..845432247 100644 --- a/src/components/input/inputContainer/inputContainer.tsx +++ b/src/components/input/inputContainer/inputContainer.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { forwardRef } from 'react'; +import React, { forwardRef } from 'react'; import { AlertInline } from '../../alerts'; import { Tag } from '../../tag'; import type { IInputContainerProps, InputVariant } from './inputContainer.api'; @@ -45,6 +45,7 @@ export const InputContainer = forwardRef(( className, wrapperClassName, id, + useCustomWrapper, ...otherProps } = props; @@ -62,6 +63,9 @@ export const InputContainer = forwardRef(( 'animate-shake': inputLength === maxLength, }); + const InputWrapper = useCustomWrapper ? React.Fragment : 'div'; + const containerProps = useCustomWrapper ? {} : { className: containerClasses }; + return (
{(label != null || helpText != null) && ( @@ -78,7 +82,7 @@ export const InputContainer = forwardRef(( )} )} -
{children}
+ {children} {maxLength != null && !alert && (

{inputLength}/{maxLength} diff --git a/src/components/input/inputFileAvatar/index.tsx b/src/components/input/inputFileAvatar/index.tsx new file mode 100644 index 000000000..a2189aec4 --- /dev/null +++ b/src/components/input/inputFileAvatar/index.tsx @@ -0,0 +1,2 @@ +export { InputFileAvatar } from './inputFileAvatar'; +export { InputFileAvatarError, type IInputFileAvatarProps } from './inputFileAvatar.api'; diff --git a/src/components/input/inputFileAvatar/inputFileAvatar.api.ts b/src/components/input/inputFileAvatar/inputFileAvatar.api.ts new file mode 100644 index 000000000..b3a7ce735 --- /dev/null +++ b/src/components/input/inputFileAvatar/inputFileAvatar.api.ts @@ -0,0 +1,56 @@ +import { type Accept } from 'react-dropzone'; +import { type IInputComponentProps } from '../inputContainer'; + +export enum InputFileAvatarError { + SQUARE_ONLY = 'square-only', + WRONG_DIMENSION = 'wrong-dimension', + UNKNOWN_ERROR = 'unknown-file-error', + FILE_INVALID_TYPE = 'file-invalid-type', + TOO_MANY_FILES = 'too-many-files', + FILE_TOO_LARGE = 'file-too-large', +} + +export interface IInputFileAvatarProps + extends Omit< + IInputComponentProps, + | 'maxLength' + | 'onChange' + | 'inputLength' + | 'wrapperClassName' + | 'useCustomWrapper' + | 'inputClassName' + | 'multiple' + > { + /** + * Function that is called when a file is selected. Passes the file to the parent component. + * If the file is rejected, the function is not called. + * If the file is accepted, the function is called with the file as an argument. + */ + onFileSelect?: (file: File) => void; + /** + * Function that is called when a file is rejected. Passes the error message to the parent component. + */ + onFileError?: (error: InputFileAvatarError) => void; + /** + * Allowed file extensions, it must be an object with the keys set to the MIME type + * and the values an array of file extensions (see https://developer.mozilla.org/en-US/docs/Web/API/window/showOpenFilePicker#accept) + * @default { 'image/png': [], 'image/gif': [], 'image/jpeg': ['.jpg', '.jpeg'] } + */ + acceptedFileTypes?: Accept; + /** + * Maximum file size in bytes (e.g. 2097152 bytes | 2 * 1024 ** 2 = 2MiB). + */ + maxFileSize?: number; + /** + * Minimum dimension of the image in pixels. + */ + minDimension?: number; + /** + * Maximum dimension of the image in pixels. + */ + maxDimension?: number; + /** + * If true, only square images are accepted. + */ + onlySquare?: boolean; +} diff --git a/src/components/input/inputFileAvatar/inputFileAvatar.stories.tsx b/src/components/input/inputFileAvatar/inputFileAvatar.stories.tsx new file mode 100644 index 000000000..f6591a3ca --- /dev/null +++ b/src/components/input/inputFileAvatar/inputFileAvatar.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { InputFileAvatar } from './inputFileAvatar'; + +const meta: Meta = { + title: 'components/Input/InputFileAvatar', + component: InputFileAvatar, + tags: ['autodocs'], + + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/ISSDryshtEpB7SUSdNqAcw/branch/jfKRr1V9evJUp1uBeyP3Zz/Aragon-ODS?type=design&node-id=11970%3A18464&mode=design&t=vme2iG22v3jenK5f-1', + }, + }, +}; + +type Story = StoryObj; + +/** + * Default usage example of the InputFileAvatar component. + */ +export const Default: Story = {}; + +export default meta; diff --git a/src/components/input/inputFileAvatar/inputFileAvatar.test.tsx b/src/components/input/inputFileAvatar/inputFileAvatar.test.tsx new file mode 100644 index 000000000..c3c8ab5b8 --- /dev/null +++ b/src/components/input/inputFileAvatar/inputFileAvatar.test.tsx @@ -0,0 +1,104 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { IconType } from '../../icon'; +import { InputFileAvatar } from './inputFileAvatar'; +import { InputFileAvatarError, type IInputFileAvatarProps } from './inputFileAvatar.api'; + +Object.defineProperty(URL, 'createObjectURL', { value: jest.fn(), configurable: true }); +Object.defineProperty(URL, 'revokeObjectURL', { value: jest.fn(), configurable: true }); + +describe(' component', () => { + const createObjectURLMock = jest.spyOn(URL, 'createObjectURL'); + const revokeObjectURLMock = jest.spyOn(URL, 'revokeObjectURL'); + + const originalGlobalImage = window.Image; + + beforeEach(() => { + (window.Image as unknown) = class MockImage { + onload: () => void = () => {}; + onerror: () => void = () => {}; + src: string = 'test'; + + removeEventListener = jest.fn(); + addEventListener = (event: string, callback: () => void) => { + if (event === 'load') { + this.onload = callback; + } else if (event === 'error') { + this.onerror = callback; + } + }; + + constructor() { + setTimeout(() => this.onload(), 100); + } + }; + }); + + afterEach(() => { + window.Image = originalGlobalImage; + createObjectURLMock.mockReset(); + revokeObjectURLMock.mockReset(); + }); + + const createTestComponent = (props?: Partial) => { + const completeProps = { ...props }; + + return ; + }; + + it('renders a file input and an add icon', () => { + const label = 'input-label'; + render(createTestComponent({ label })); + const fileInput = screen.getByLabelText(label); + expect(fileInput).toBeInTheDocument(); + expect(fileInput.type).toEqual('file'); + expect(screen.getByTestId(IconType.ADD)).toBeInTheDocument(); + }); + + it('displays a preview and calls the onFileSelect callback when a valid file is selected', async () => { + const label = 'test-label'; + const fileSrc = 'https://chucknorris.com/image.png'; + const file = new File(['(⌐□_□)'], fileSrc, { type: 'image/png' }); + const onFileSelect = jest.fn(); + createObjectURLMock.mockReturnValue(fileSrc); + + render(createTestComponent({ label, onFileSelect })); + await userEvent.upload(screen.getByLabelText(label), file); + const previewImg = await screen.findByRole('img'); + + expect(previewImg).toBeInTheDocument(); + expect(previewImg.src).toEqual(fileSrc); + expect(onFileSelect).toHaveBeenCalledWith(file); + }); + + it('clears the current file selection on close button click after an image has been selected', async () => { + const label = 'test-label'; + const file = new File(['something'], 'test.png', { type: 'image/png' }); + createObjectURLMock.mockReturnValue('file-src'); + + render(createTestComponent({ label })); + await userEvent.upload(screen.getByLabelText(label), file); + const cancelButton = await screen.findByRole('button'); + expect(cancelButton).toBeInTheDocument(); + + await userEvent.click(cancelButton); + expect(screen.getByTestId(IconType.ADD)).toBeInTheDocument(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('calls onFileError when file has incorrect dimensions', async () => { + const originalWidth = global.Image.prototype.width; + global.Image.prototype.width = 800; + + const label = 'test-label'; + const file = new File(['test'], 'test.png', { type: 'image/png' }); + const onFileError = jest.fn(); + const minDimension = 1000; + + render(createTestComponent({ label, onFileError, minDimension })); + await userEvent.upload(screen.getByLabelText(label), file); + await waitFor(() => expect(onFileError).toHaveBeenCalledWith(InputFileAvatarError.WRONG_DIMENSION)); + + global.Image.prototype.width = originalWidth; + }); +}); diff --git a/src/components/input/inputFileAvatar/inputFileAvatar.tsx b/src/components/input/inputFileAvatar/inputFileAvatar.tsx new file mode 100644 index 000000000..89009a2e4 --- /dev/null +++ b/src/components/input/inputFileAvatar/inputFileAvatar.tsx @@ -0,0 +1,155 @@ +import classNames from 'classnames'; +import { useCallback, useState } from 'react'; +import { ErrorCode, useDropzone, type FileRejection } from 'react-dropzone'; +import { Avatar } from '../../avatars'; +import { Icon, IconType } from '../../icon'; +import { Spinner } from '../../spinner'; +import { useInputProps } from '../hooks'; +import { InputContainer, type InputVariant } from '../inputContainer'; +import { InputFileAvatarError, type IInputFileAvatarProps } from './inputFileAvatar.api'; + +const stateToClassNames: Record = { + default: { + containerClasses: [ + 'border-[1px] border-neutral-100 hover:border-neutral-200 border-dashed cursor-pointer focus-visible:ring-primary', + ], + addIconClasses: ['text-neutral-400 group-hover:text-neutral-600'], + }, + warning: { + containerClasses: [ + 'border-[1px] border-warning-300 hover:border-warning-400 border-dashed cursor-pointer focus-visible:ring-warning', + ], + addIconClasses: ['text-warning-500 group-hover:text-warning-600'], + }, + critical: { + containerClasses: [ + 'border-[1px] border-critical-500 hover:border-critical-600 border-dashed cursor-pointer focus-visible:ring-critical', + ], + addIconClasses: ['text-critical-500 group-hover:text-critical-600'], + }, + disabled: { + containerClasses: ['border-[1px] border-neutral-200'], + addIconClasses: ['text-neutral-200'], + }, +}; + +const dropzoneErrorToError: Record = { + [ErrorCode.FileInvalidType]: InputFileAvatarError.FILE_INVALID_TYPE, + [ErrorCode.FileTooLarge]: InputFileAvatarError.FILE_TOO_LARGE, + [ErrorCode.TooManyFiles]: InputFileAvatarError.TOO_MANY_FILES, +}; + +export const InputFileAvatar: React.FC = ({ + onFileSelect, + onFileError, + maxFileSize, + minDimension, + maxDimension, + acceptedFileTypes = { 'image/png': [], 'image/gif': [], 'image/jpeg': ['.jpg', '.jpeg'] }, + onlySquare, + variant = 'default', + isDisabled, + ...otherProps +}) => { + const [imagePreview, setImagePreview] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const { containerProps } = useInputProps(otherProps); + const { id, ...otherContainerProps } = containerProps; + + const onDrop = useCallback( + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { + if (rejectedFiles.length > 0) { + const dropzoneError = rejectedFiles[0].errors[0].code; + const internalError = dropzoneErrorToError[dropzoneError] ?? InputFileAvatarError.UNKNOWN_ERROR; + onFileError?.(internalError); + + return; + } + + const file = acceptedFiles[0]; + const image = new Image(); + setIsLoading(true); + + const onImageLoad = () => { + const isBelowMinDimension = minDimension && (image.width < minDimension || image.height < minDimension); + const isAboveMaxDimension = maxDimension && (image.width > maxDimension || image.height > maxDimension); + + if (onlySquare && image.height !== image.width) { + onFileError?.(InputFileAvatarError.SQUARE_ONLY); + } else if (isBelowMinDimension ?? isAboveMaxDimension) { + onFileError?.(InputFileAvatarError.WRONG_DIMENSION); + } else { + setImagePreview(image.src); + onFileSelect?.(file); + } + + setIsLoading(false); + }; + + image.addEventListener('load', onImageLoad); + image.addEventListener('error', () => { + setIsLoading(false); + onFileError?.(InputFileAvatarError.UNKNOWN_ERROR); + }); + + image.src = URL.createObjectURL(file); + }, + [maxDimension, minDimension, onFileError, onFileSelect, onlySquare], + ); + + const { getRootProps, getInputProps } = useDropzone({ + accept: acceptedFileTypes, + maxSize: maxFileSize, + disabled: isDisabled, + onDrop, + multiple: false, + }); + + const handleCancel = (event: React.MouseEvent) => { + event.stopPropagation(); + setImagePreview(null); + if (imagePreview) { + URL.revokeObjectURL(imagePreview); + } + }; + + const processedVariant = isDisabled ? 'disabled' : variant; + const { containerClasses, addIconClasses } = stateToClassNames[processedVariant]; + + const inputAvatarClassNames = classNames( + 'group flex size-16 items-center justify-center rounded-full bg-neutral-0 hover:shadow-neutral', + 'focus:outline-none focus-visible:ring focus-visible:ring-offset', + containerClasses, + ); + + return ( + +

+ + {imagePreview ? ( +
+ + +
+ ) : ( + <> + {isLoading && } + {!imagePreview && !isLoading && ( + + )} + + )} +
+ + ); +}; diff --git a/yarn.lock b/yarn.lock index d8d92c413..d68c9eb84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4537,6 +4537,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== +attr-accept@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b" + integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg== + autoprefixer@^10.4.17: version "10.4.17" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.17.tgz#35cd5695cbbe82f536a50fa025d561b01fdec8be" @@ -6506,6 +6511,13 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-selector@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc" + integrity sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw== + dependencies: + tslib "^2.4.0" + file-system-cache@2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/file-system-cache/-/file-system-cache-2.3.0.tgz#201feaf4c8cd97b9d0d608e96861bb6005f46fe6" @@ -10107,6 +10119,15 @@ react-dom@^18.0.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-dropzone@^14.2.0: + version "14.2.3" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b" + integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug== + dependencies: + attr-accept "^2.2.2" + file-selector "^0.6.0" + prop-types "^15.8.1" + react-element-to-jsx-string@^15.0.0: version "15.0.0" resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz#1cafd5b6ad41946ffc8755e254da3fc752a01ac6"