diff --git a/.changeset/deep-chairs-raise.md b/.changeset/deep-chairs-raise.md new file mode 100644 index 0000000000..1c5f8e3d91 --- /dev/null +++ b/.changeset/deep-chairs-raise.md @@ -0,0 +1,5 @@ +--- +"@ultraviolet/ui": minor +--- + +New component `FileInput` diff --git a/packages/ui/src/components/FileInput/FileInputProvider.tsx b/packages/ui/src/components/FileInput/FileInputProvider.tsx new file mode 100644 index 0000000000..9a25c7948c --- /dev/null +++ b/packages/ui/src/components/FileInput/FileInputProvider.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from 'react' + +type FileInputContextType = + | { + disabled?: boolean + inputId: string + } + | undefined +export const FileInputContext = createContext(undefined) + +export const useFileInput = () => { + const context = useContext(FileInputContext) + + if (!context) { + throw new Error( + 'FileInputContext should be inside FileInput to work properly.', + ) + } + + return context +} diff --git a/packages/ui/src/components/FileInput/__stories__/Children.stories.tsx b/packages/ui/src/components/FileInput/__stories__/Children.stories.tsx new file mode 100644 index 0000000000..8eef97d666 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Children.stories.tsx @@ -0,0 +1,65 @@ +import type { StoryFn } from '@storybook/react-vite' +import { Stack } from '../../Stack' +import { Text } from '../../Text' +import { FileInput } from '..' +import { hereText } from './styles.css' + +export const Children: StoryFn = args => ( + + ( + + )} + variant="dropzone" + > + {inputId => ( + + + You can also click here (children) + + But not here + + )} + + + {inputId => ( + <> + Drag an drop on me or click{' '} + + here + {' '} + to add a file + + )} + + +) + +Children.parameters = { + docs: { + description: { + story: + 'You can get the input id from the component if you want to use it on some other components, not just `FileInput.Button`. Simply use a label and `htmlFor`. You can also do it with the title.', + }, + }, +} diff --git a/packages/ui/src/components/FileInput/__stories__/Controlled.stories.tsx b/packages/ui/src/components/FileInput/__stories__/Controlled.stories.tsx new file mode 100644 index 0000000000..14cbc52ab9 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Controlled.stories.tsx @@ -0,0 +1,43 @@ +import type { StoryFn } from '@storybook/react-vite' +import { useState } from 'react' +import { Stack } from '../../Stack' +import { Text } from '../../Text' +import { FileInput } from '..' +import type { FilesType } from '../types' + +export const Controlled: StoryFn = args => { + const [files, setFiles] = useState([]) + const onChange = (f: FilesType[]) => setFiles(f) + + return ( + + + + Files: +
    + {files.map(file => ( +
  • {file.fileName}
  • + ))} +
+
+
+ ) +} + +Controlled.parameters = { + docs: { + description: { + story: + 'The component can be controlled two ways: with the `onDrop` prop to catch the event or with `onChangeFiles` to get a more polished list of the added files.', + }, + }, +} diff --git a/packages/ui/src/components/FileInput/__stories__/Disabled.stories.tsx b/packages/ui/src/components/FileInput/__stories__/Disabled.stories.tsx new file mode 100644 index 0000000000..be7c99b4ad --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Disabled.stories.tsx @@ -0,0 +1,49 @@ +import type { StoryFn } from '@storybook/react-vite' +import { PlusIcon, UploadIcon } from '@ultraviolet/icons' +import { Stack } from '../../Stack' +import { FileInput } from '..' + +export const Disabled: StoryFn = args => ( + + + Only pay for the storage you use. For example, storing 100 GB of data will + cost use monthly less than a cup of coffee. + + + + Add folder + + + + Upload + + + + + + + Click or drag file to this area to upload (disabled) + + } + variant="overlay" + > + Some content (drag on me) + + +) diff --git a/packages/ui/src/components/FileInput/__stories__/DropzoneSize.stories.tsx b/packages/ui/src/components/FileInput/__stories__/DropzoneSize.stories.tsx new file mode 100644 index 0000000000..dd026671f6 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/DropzoneSize.stories.tsx @@ -0,0 +1,48 @@ +import type { StoryFn } from '@storybook/react-vite' +import { PlusIcon, UploadIcon } from '@ultraviolet/icons' +import { Link } from '../../Link' +import { Stack } from '../../Stack' +import { FileInput } from '..' + +export const DropzoneSize: StoryFn = args => ( + + + Only pay for the storage you use. For example, storing 100 GB of data will + cost use monthly less than a cup of coffee. + + Object Storage pricing + + + + + Add folder + + + + Upload + + + + + +) + +DropzoneSize.parameters = { + docs: { + description: { + story: + 'There are two sizes for the fileinput when variant="dropdzone" (default value).
⚠️ When size="medium" (default value), do not forget to use `FileInput.Button` in order to add button to open the file explorer ! ⚠️', + }, + }, +} diff --git a/packages/ui/src/components/FileInput/__stories__/List.stories.tsx b/packages/ui/src/components/FileInput/__stories__/List.stories.tsx new file mode 100644 index 0000000000..a4d110f436 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/List.stories.tsx @@ -0,0 +1,112 @@ +import type { StoryFn } from '@storybook/react-vite' +import { Separator } from '../../Separator' +import { Stack } from '../../Stack' +import { FileInput } from '..' + +const defaultFile = [ + { + file: 'https://upload.wikimedia.org/wikipedia/commons/4/41/Photo_Chat_Noir_et_blanc.jpg', + fileName: 'cat.png', + lastModified: 1, + size: 30460, + type: 'image/png', + }, + { + file: 'sound.mp3', + fileName: 'sound.mp3', + lastModified: 1, + size: 30460, + type: 'audio/mp3', + }, + { + file: 'doc.pdf', + fileName: 'doc.pdf', + lastModified: 1, + size: 304600, + type: 'application/pdf', + }, + { + file: 'video.mp4', + fileName: 'video.mp4', + lastModified: 1, + size: 40460000, + type: 'video/mp4', + }, + { + file: 'loading.pdf', + fileName: 'loading_example.pdf', + lastModified: 1, + loading: true, + size: 40460000, + type: 'application/pdf', + }, + { + error: 'Maximum file size exceeded', + file: 'error.png', + fileName: 'error_example.png', + lastModified: 1, + size: 4046000000, + type: 'image/png', + }, +] +export const List: StoryFn = args => ( + + + + + Type "ovelay" (listPosition="top") + + + + Type "ovelay" (listPosition="bottom") + + + + +) + +List.parameters = { + docs: { + description: { + story: + 'With prop `list` it is possible to display all the drag&drop files added to the input. When using the FileInput as an overlay, it is possible to add the list on top or beneath the content using prop `listPosition`. Size is displayed and computed automatically from file.sie (number, in byte). It is also possible to add a limit to the number of visible files in the list using prop `listLimit`.', + }, + }, +} diff --git a/packages/ui/src/components/FileInput/__stories__/Multiple.stories.tsx b/packages/ui/src/components/FileInput/__stories__/Multiple.stories.tsx new file mode 100644 index 0000000000..b389803b9d --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Multiple.stories.tsx @@ -0,0 +1,44 @@ +import type { StoryFn } from '@storybook/react-vite' +import { Stack } from '../../Stack' +import { FileInput } from '..' + +const defaultFile = [ + { + file: 'https://upload.wikimedia.org/wikipedia/commons/4/41/Photo_Chat_Noir_et_blanc.jpg', + fileName: 'cat.png', + lastModified: 1, + size: 30460, + type: 'image/png', + }, +] +export const Multiple: StoryFn = args => ( + + + + +) + +Multiple.parameters = { + docs: { + description: { + story: 'It is possible to add mutliple files when using prop `multiple`.', + }, + }, +} diff --git a/packages/ui/src/components/FileInput/__stories__/Overlay.stories.tsx b/packages/ui/src/components/FileInput/__stories__/Overlay.stories.tsx new file mode 100644 index 0000000000..d679e6ac0b --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Overlay.stories.tsx @@ -0,0 +1,110 @@ +import type { StoryFn } from '@storybook/react-vite' +import { RebootIcon, SendIcon, UploadIcon } from '@ultraviolet/icons' +import { useRef, useState } from 'react' +import { Avatar } from '../../Avatar' +import { Button } from '../../Button' +import { Stack } from '../../Stack' +import { TextInput } from '../../TextInput' +import { FileInput } from '..' +import { promptContainer, promptInput } from './styles.css' + +const Prompt = ({ inputId }: { inputId: string }) => ( + + + + + + +) + +export const Overlay: StoryFn = args => { + const [image, setImage] = useState(undefined) + const inputRef = useRef(null) + + return ( + + Drag files in here to see the component + + {image ? ( + inputRef?.current?.click()} + shape="square" + upload + variant="image" + /> + ) : ( + inputRef?.current?.click()} + shape="square" + text="UV" + upload + variant="text" + /> + )} + { + if (event.target.files) { + setImage(URL.createObjectURL(event.target.files[0])) + } + }} + ref={inputRef} + style={{ display: 'none' }} + type="file" + /> + + + Some content (this is also an overlay) + + + Drag file to this area to upload + + } + variant="overlay" + > + {inputId => } + + + ) +} +Overlay.parameters = { + docs: { + description: { + story: + 'FileInput can be used as an overlay over any component. When a title is set, it replaces the children when dragging.', + }, + }, +} diff --git a/packages/ui/src/components/FileInput/__stories__/Playground.stories.tsx b/packages/ui/src/components/FileInput/__stories__/Playground.stories.tsx new file mode 100644 index 0000000000..365b5253d0 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Playground.stories.tsx @@ -0,0 +1,5 @@ +import { Template } from './Template.stories' + +export const Playground = Template.bind({}) + +Playground.args = { ...Template.args } diff --git a/packages/ui/src/components/FileInput/__stories__/Template.stories.tsx b/packages/ui/src/components/FileInput/__stories__/Template.stories.tsx new file mode 100644 index 0000000000..ecb69d8f09 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Template.stories.tsx @@ -0,0 +1,27 @@ +import type { StoryFn } from '@storybook/react-vite' +import { PlusIcon, UploadIcon } from '@ultraviolet/icons' +import { Button } from '../../Button' +import { Stack } from '../../Stack' +import { FileInput } from '..' + +export const Template: StoryFn = args => ( + +) + +Template.args = { + children: ( + + + + Add folder + + + + ), + helper: 'Helper', + label: 'Label', + title: 'Drag and drop files here', +} diff --git a/packages/ui/src/components/FileInput/__stories__/index.stories.tsx b/packages/ui/src/components/FileInput/__stories__/index.stories.tsx new file mode 100644 index 0000000000..8de1761068 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/index.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta } from '@storybook/react-vite' +import { FileInput } from '..' + +export default { + component: FileInput, + decorators: [StoryComponent => ], + + title: 'Components/Data Entry/FileInput', +} as Meta + +export { Playground } from './Playground.stories' +export { DropzoneSize } from './DropzoneSize.stories' +export { Overlay } from './Overlay.stories' +export { Multiple } from './Multiple.stories' +export { Disabled } from './Disabled.stories' +export { Children } from './Children.stories' +export { List } from './List.stories' +export { Controlled } from './Controlled.stories' diff --git a/packages/ui/src/components/FileInput/__stories__/styles.css.ts b/packages/ui/src/components/FileInput/__stories__/styles.css.ts new file mode 100644 index 0000000000..16f6541ee3 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/styles.css.ts @@ -0,0 +1,21 @@ +import { theme } from '@ultraviolet/themes' +import { style } from '@vanilla-extract/css' + +export const hereText = style({ + cursor: 'pointer', + selectors: { + '&:hover': { + textDecoration: 'underline', + }, + }, +}) + +export const promptContainer = style({ + padding: theme.space[3], + paddingBottom: theme.space[1], + background: theme.colors.neutral.backgroundWeak, + border: `1px solid ${theme.colors.neutral.border}`, +}) +export const promptInput = style({ + width: 500, +}) diff --git a/packages/ui/src/components/FileInput/__tests__/__snapshots__/index.test.tsx.snap b/packages/ui/src/components/FileInput/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..b00fdbc1f2 --- /dev/null +++ b/packages/ui/src/components/FileInput/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,2727 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`fileInput > renders correctly 1`] = ` + +
+
+ +
+
+ + + + + +

+ title +

+
+
+

+ helper +

+
+
+
+`; + +exports[`fileInput > renders correctly as an overlay 1`] = ` + +
+
+
+ +
+ test +
+
+
+
+
+ +`; + +exports[`fileInput > renders correctly disabled 1`] = ` + +
+
+
+
+ + + + +

+ +

+
+
+
+
+`; + +exports[`fileInput > renders correctly onChange 1`] = ` + +
+
+
+
+ + + + + +

+

+
+
+
+
+
+ +
+

+ cat.png +

+

+ 30.46 KB +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ error_example.png +

+

+ 4.05 GB +

+
+
+ +
+

+ Maximum file size exceeded +

+
+
+
+
+
+ + + +
+
+

+ doc.pdf +

+

+ 304.6 KB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ video.mp4 +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ loading_example.pdf +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+`; + +exports[`fileInput > renders correctly ondrop, ondrag 1`] = ` + +
+
+
+ +
+ nodrag +
+
+
+
+
+
+
+ +
+

+ cat.png +

+

+ 30.46 KB +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ error_example.png +

+

+ 4.05 GB +

+
+
+ +
+

+ Maximum file size exceeded +

+
+
+
+
+
+ + + + + + + +
+
+

+ sound.mp3 +

+

+ 0 B +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ doc.pdf +

+

+ 304.6 KB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ video.mp4 +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ loading_example.pdf +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+ +`; + +exports[`fileInput > renders correctly small 1`] = ` + +
+
+ +
+
+ + + + + + +
+
+
+
+
+`; + +exports[`fileInput > renders correctly with FileInput.Button 1`] = ` + +
+
+
+
+ + + + + +

+ +

+
+
+
+
+
+ +
+

+ cat.png +

+

+ 30.46 KB +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ error_example.png +

+

+ 4.05 GB +

+
+
+ +
+

+ Maximum file size exceeded +

+
+
+
+
+
+ + + + + + + +
+
+

+ sound.mp3 +

+

+ 0 B +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ doc.pdf +

+

+ 304.6 KB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ video.mp4 +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ loading_example.pdf +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+`; + +exports[`fileInput > renders correctly with multiple and list 1`] = ` + +
+
+
+
+ + + + + +

+

+
+
+
+
+
+ +
+

+ cat.png +

+

+ 30.46 KB +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ error_example.png +

+

+ 4.05 GB +

+
+
+ +
+

+ Maximum file size exceeded +

+
+
+
+
+
+ + + + + + + +
+
+

+ sound.mp3 +

+

+ 0 B +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ doc.pdf +

+

+ 304.6 KB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ video.mp4 +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ loading_example.pdf +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+`; + +exports[`fileInput > should handle adding a file when selecting via the hidden file input 1`] = ` + +
+
+
+
+ + + + + +

+

+
+
+
+
+
+
+ + + +
+
+

+ upload.png +

+

+ 5 B +

+
+
+ +
+
+
+
+
+
+`; + +exports[`fileInput > should handle drag state in dropzone variant 1`] = ` + +
+
+
+
+ + + + + +

+ upload files +

+
+
+
+
+
+`; + +exports[`fileInput > should work correctly with listLimit 1`] = ` + +
+
+
+
+ + + + + +

+

+
+
+
+
+
+ +
+

+ cat.png +

+

+ 30.46 KB +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ error_example.png +

+

+ 4.05 GB +

+
+
+ +
+

+ Maximum file size exceeded +

+
+
+
+
+
+ + + + + + + +
+
+

+ sound.mp3 +

+

+ 0 B +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ doc.pdf +

+

+ 304.6 KB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ video.mp4 +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ loading_example.pdf +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+`; + +exports[`fileInput > should work with function children and title 1`] = ` + +
+
+
+
+ + + + + +

+ +

+ +
+
+
+
+
+
+ +
+

+ cat.png +

+

+ 30.46 KB +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ error_example.png +

+

+ 4.05 GB +

+
+
+ +
+

+ Maximum file size exceeded +

+
+
+
+
+
+ + + + + + + +
+
+

+ sound.mp3 +

+

+ 0 B +

+
+
+ +
+
+
+
+
+
+ + + +
+
+

+ doc.pdf +

+

+ 304.6 KB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ video.mp4 +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+ + + + +
+
+

+ loading_example.pdf +

+

+ 40.46 MB +

+
+
+ +
+
+
+
+
+
+`; diff --git a/packages/ui/src/components/FileInput/__tests__/index.test.tsx b/packages/ui/src/components/FileInput/__tests__/index.test.tsx new file mode 100644 index 0000000000..9c48095a4c --- /dev/null +++ b/packages/ui/src/components/FileInput/__tests__/index.test.tsx @@ -0,0 +1,278 @@ +import { fireEvent, screen } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import { renderWithTheme, shouldMatchSnapshot } from '@utils/test' +import { describe, expect, test, vi } from 'vitest' +import { FileInput } from '..' + +const defaultFile = [ + { + file: 'https://upload.wikimedia.org/wikipedia/commons/4/41/Photo_Chat_Noir_et_blanc.jpg', + fileName: 'cat.png', + lastModified: 1, + size: 30460, + type: 'image/png', + }, + { + error: 'Maximum file size exceeded', + file: 'error.png', + fileName: 'error_example.png', + lastModified: 1, + size: 4046000000, + type: 'image/png', + }, + { + file: 'sound.mp3', + fileName: 'sound.mp3', + lastModified: 1, + size: 0, + type: 'audio/mp3', + }, + { + file: 'doc.pdf', + fileName: 'doc.pdf', + lastModified: 1, + size: 304600, + type: 'application/pdf', + }, + { + file: 'video.mp4', + fileName: 'video.mp4', + lastModified: 1, + size: 40460000, + type: 'video/png', + }, + { + file: 'loading.pdf', + fileName: 'loading_example.pdf', + lastModified: 1, + loading: true, + size: 40460000, + type: 'application/pdf', + }, +] + +describe('fileInput', () => { + test('renders correctly', () => { + const { asFragment } = renderWithTheme( + , + ) + expect(asFragment()).toMatchSnapshot() + }) + test('renders correctly as an overlay', () => { + const { asFragment } = renderWithTheme( + + test + , + ) + expect(asFragment()).toMatchSnapshot() + }) + test('renders correctly small', () => { + const { asFragment } = renderWithTheme( + , + ) + expect(asFragment()).toMatchSnapshot() + }) + + test('renders correctly with multiple and list', () => { + const { asFragment } = renderWithTheme( + , + ) + expect(asFragment()).toMatchSnapshot() + }) + + test('renders correctly disabled', () => { + const { asFragment } = renderWithTheme( + + + Disabled button + + , + ) + + expect(screen.getByTestId('button')).toBeDisabled() + expect(asFragment()).toMatchSnapshot() + }) + + test('renders correctly onChange', async () => { + const onChange = vi.fn() + const { asFragment } = renderWithTheme( + , + ) + + const soundMp3File = screen.getByTestId('sound.mp3') + const closeButton = screen.getByTestId('remove-sound.mp3') + + expect(soundMp3File).toBeInTheDocument() + await userEvent.click(closeButton) + expect(soundMp3File).not.toBeInTheDocument() + + expect(asFragment()).toMatchSnapshot() + }) + + test('should work correctly with listLimit', async () => { + const onChange = vi.fn() + const { asFragment } = renderWithTheme( + , + ) + + const nonOverflowedElement = screen.getByTestId('sound.mp3') + + expect(screen.queryByTestId('video.mp4')).not.toBeInTheDocument() + expect(nonOverflowedElement).toBeInTheDocument() + + const seeAllButton = screen.getByTestId('see-all') + await userEvent.click(seeAllButton) + + expect(screen.getByTestId('video.mp4')).toBeInTheDocument() + expect(nonOverflowedElement).toBeInTheDocument() + + expect(asFragment()).toMatchSnapshot() + }) + test('renders correctly with FileInput.Button', async () => { + const onChange = vi.fn() + const { asFragment } = renderWithTheme( + + button + , + ) + + expect(asFragment()).toMatchSnapshot() + }) + + test('should throw error with FileInput.Button outside of FileInput', async () => { + expect(() => + shouldMatchSnapshot(button), + ).toThrowError( + 'FileInputContext should be inside FileInput to work properly.', + ) + }) + + test('should work with function children and title', async () => { + const onChange = vi.fn() + const { asFragment } = renderWithTheme( + } + > + {inputId => } + , + ) + + expect(asFragment()).toMatchSnapshot() + }) + + test('renders correctly ondrop, ondrag', async () => { + const onChange = vi.fn() + const { asFragment } = renderWithTheme( + + nodrag + , + ) + + const defaultcontent = screen.getByText('nodrag') + const dragContainer = screen.getByTestId('drag-container') + fireEvent.dragOver(dragContainer) + fireEvent.drop(dragContainer) + expect(defaultcontent).toBeVisible() + + expect(asFragment()).toMatchSnapshot() + }) + + test('should handle drag state in dropzone variant', async () => { + const { asFragment } = renderWithTheme( + , + ) + + const dropzoneElement = screen.getByTestId('drag-container') + fireEvent.dragOver(dropzoneElement) + fireEvent.drop(dropzoneElement) + + expect(asFragment()).toMatchSnapshot() + }) + + test('should handle adding a file when selecting via the hidden file input', async () => { + const onChangeFiles = vi.fn() + const { asFragment } = renderWithTheme( + , + ) + + const input = screen.getByTestId('test') + + const file = new File(['hello'], 'upload.png', { type: 'application/pdf' }) + await userEvent.upload(input, file) + + expect(onChangeFiles).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ fileName: 'upload.png' }), + ]), + ) + + const added = screen.getByTestId('upload.png') + expect(added).toBeInTheDocument() + + expect(asFragment()).toMatchSnapshot() + }) + + test('should add a file with drag and drop', async () => { + const onChangeFiles = vi.fn() + renderWithTheme( + , + ) + + const dropzone = screen.getByTestId('drag-container') + const file = new File(['dnd'], 'dnd.png', { type: 'image/png' }) + + fireEvent.drop(dropzone, { + dataTransfer: { + files: [file], + items: [], + types: ['Files'], + }, + } as unknown as DragEvent) + + expect(onChangeFiles).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ fileName: 'dnd.png' }), + ]), + ) + + const added = screen.getByTestId('dnd.png') + expect(added).toBeInTheDocument() + }) +}) diff --git a/packages/ui/src/components/FileInput/components/Button.tsx b/packages/ui/src/components/FileInput/components/Button.tsx new file mode 100644 index 0000000000..f0f5c5bd3d --- /dev/null +++ b/packages/ui/src/components/FileInput/components/Button.tsx @@ -0,0 +1,25 @@ +import type { ComponentProps } from 'react' +import { Button } from '../../Button' +import { useFileInput } from '../FileInputProvider' +import { buttonFileInput } from '../styles.css' + +export const FileInputButton = ({ + children, + disabled, + ...props +}: ComponentProps) => { + const context = useFileInput() + const isDisabled = disabled || context.disabled + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ) +} diff --git a/packages/ui/src/components/FileInput/components/List.tsx b/packages/ui/src/components/FileInput/components/List.tsx new file mode 100644 index 0000000000..9af0830483 --- /dev/null +++ b/packages/ui/src/components/FileInput/components/List.tsx @@ -0,0 +1,153 @@ +import { + AudioIcon, + CloseIcon, + DocIcon, + ImageIcon, + VideoIcon, +} from '@ultraviolet/icons' +import { useState } from 'react' +import { Button } from '../../Button' +import { Loader } from '../../Loader' +import { Stack } from '../../Stack' +import { Text } from '../../Text' +import { formatFileSize, getMimeTypeType } from '../helpers' +import { fileViewerContainer, fileViewerImage } from '../styles.css' +import type { ListFilesProps, MimeType } from '../types' + +const getIllustration = ( + type: MimeType, + file: string, + error: boolean, + loading?: boolean, +) => { + if (loading) { + return ( +
+ +
+ ) + } + if (type === 'audio') { + return ( +
+ +
+ ) + } + if (type === 'video') { + return ( +
+ +
+ ) + } + if (type === 'image' && !error) { + return + } + + if (type === 'image' && error) { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} + +export const ListFiles = ({ + files, + setFiles, + onChangeFiles, + listLimit, +}: ListFilesProps) => { + const [limit, setLimit] = useState(listLimit?.limit) + const seeAllOnClick = () => { + setLimit(undefined) + } + + return ( + + {files.map((file, index) => { + if (!limit || index < limit) { + const fileType = getMimeTypeType(file.type) + const illustration = getIllustration( + fileType, + file.file, + !!file.error, + file.loading, + ) + const sentiment = file.error ? 'danger' : 'neutral' + + return ( + + + + {illustration} + + + {file.fileName} + + + {formatFileSize(file.size)} + + + + + + {file.error ? ( + + {file.error} + + ) : null} + + ) + } + + return null + })} + {limit && files.length > limit ? ( + + ) : null} + + ) +} diff --git a/packages/ui/src/components/FileInput/helpers.ts b/packages/ui/src/components/FileInput/helpers.ts new file mode 100644 index 0000000000..f7a6dcce32 --- /dev/null +++ b/packages/ui/src/components/FileInput/helpers.ts @@ -0,0 +1,26 @@ +import type { MimeType } from './types' + +export const getMimeTypeType = (mimeType: string): MimeType => + (mimeType.split('/')?.[0]?.toLowerCase() || 'example') as MimeType + +export const formatFileSize = (bytes: number): string => { + if (bytes === 0) { + return '0 B' + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB'] as const + + let size = bytes + let unitIndex = 0 + + while (size >= 1000 && unitIndex < units.length - 1) { + size /= 1000 + unitIndex += 1 + } + + // Format to 2 decimal + const formattedSize = + size % 1 === 0 ? size : Number.parseFloat(size.toFixed(2)) + + return `${formattedSize} ${units[unitIndex]}` +} diff --git a/packages/ui/src/components/FileInput/index.tsx b/packages/ui/src/components/FileInput/index.tsx new file mode 100644 index 0000000000..8a4f039cb0 --- /dev/null +++ b/packages/ui/src/components/FileInput/index.tsx @@ -0,0 +1,277 @@ +'use client' + +import { UploadIcon } from '@ultraviolet/icons' +import type { ChangeEvent, DragEvent } from 'react' +import { useEffect, useId, useState } from 'react' +import { Label } from '../Label' +import { Stack } from '../Stack' +import { Text } from '../Text' +import { FileInputButton } from './components/Button' +import { ListFiles } from './components/List' +import { FileInputContext } from './FileInputProvider' +import { + dropzone, + dropzoneOverlay, + dropzoneOverlayDisabled, + fileInput, + overlayWrapper, + titleSmall, +} from './styles.css' +import type { FileInputProps, FilesType } from './types' + +/** + * FileInput allow user to drag & drop and upload one or multiple files. + */ +const FileInputBase = ({ + style, + className, + variant = 'dropzone', + size = 'medium', + title, + children, + onDrop, + label, + labelDescription, + disabled, + accept, + list, + listPosition = 'bottom', + listLimit, + 'aria-label': ariaLabel, + defaultFiles, + onChangeFiles, + helper, + multiple = false, + 'data-testid': dataTestid, +}: FileInputProps) => { + const [dragState, setDragState] = useState<'over' | 'default' | 'page'>( + 'default', + ) + const [files, setFiles] = useState(defaultFiles ?? []) + + const inputId = useId() + + const onDragOver = (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + setDragState('over') + } + + const onDragPage = () => setDragState('page') + + const handleDrop = () => setDragState('default') + const handleDragLeave = (event: Event) => { + const dragEvent = event as unknown as DragEvent + + if (event.type === 'dragend' || dragEvent.relatedTarget === null) { + setDragState('default') + } + } + + useEffect(() => { + window.addEventListener('dragenter', onDragPage) + window.addEventListener('dragend', handleDragLeave) + window.addEventListener('drop', handleDrop) + window.addEventListener('dragleave', handleDragLeave) + + return () => { + window.removeEventListener('dragenter', onDragPage) + window.removeEventListener('dragend', handleDragLeave) + window.removeEventListener('drop', handleDrop) + window.removeEventListener('dragleave', handleDragLeave) + } + }, []) + + const manageDrop = (event: DragEvent) => { + event.preventDefault() + + if (!disabled) { + const droppedFiles = [...(event.dataTransfer?.files ?? [])] + const newFiles = droppedFiles.map(file => ({ + file: URL.createObjectURL(file), + fileName: file.name, + lastModified: file.lastModified, + size: file.size, + type: file.type, + })) + const formattedFiles = multiple ? [...files, ...newFiles] : newFiles + + setFiles(formattedFiles) + onDrop?.(event) + onChangeFiles?.(formattedFiles) + } + } + + const onChange = (event: ChangeEvent) => { + event.preventDefault() + + if (!disabled) { + const addedFiles = [...(event.target.files ?? [])] + + const newFiles = addedFiles.map(file => ({ + file: URL.createObjectURL(file), + fileName: file.name, + lastModified: file.lastModified, + size: file.size, + type: file.type, + })) + + const formattedFiles = multiple ? [...files, ...newFiles] : newFiles + setFiles(formattedFiles) + onChangeFiles?.(formattedFiles) + } + } + if (variant === 'overlay') { + return ( + + {list && listPosition === 'top' ? ( + + ) : null} + +
+ +
+ {typeof children === 'function' ? children(inputId) : children} +
event.preventDefault()} + onDrop={event => { + if (!disabled) { + onDrop?.(event) + manageDrop(event) + } + }} + style={style} + > + {title && + typeof title !== 'function' && + dragState !== 'default' ? ( + + {title} + + ) : null} +
+
+
+ {list && listPosition === 'bottom' ? ( + + ) : null} +
+ ) + } + + const isSmall = size === 'small' + + return ( + + + {label || labelDescription ? ( + + ) : null} + + { + if (!disabled) { + onDrop?.(event) + manageDrop(event) + } + }} + style={style} + > + {disabled ? null : ( + + )} + + + {typeof title === 'function' ? title(inputId) : title} + + {typeof children === 'function' ? children(inputId) : children} + + + {helper ? ( + + {helper} + + ) : null} + {list ? ( + + ) : null} + + + ) +} + +export const FileInput = Object.assign(FileInputBase, { + Button: FileInputButton, +}) diff --git a/packages/ui/src/components/FileInput/styles.css.ts b/packages/ui/src/components/FileInput/styles.css.ts new file mode 100644 index 0000000000..7a31c33035 --- /dev/null +++ b/packages/ui/src/components/FileInput/styles.css.ts @@ -0,0 +1,178 @@ +import { theme } from '@ultraviolet/themes' +import { style, styleVariants } from '@vanilla-extract/css' +import { recipe } from '@vanilla-extract/recipes' + +export const dropzone = recipe({ + base: { + textAlign: 'center', + border: `1px dashed ${theme.colors.neutral.borderStrong}`, + }, + variants: { + state: { + over: {}, + default: {}, + page: {}, + }, + size: { + small: { + padding: theme.space[2], + }, + medium: { + padding: theme.space[5], + }, + }, + disabled: { + true: { + cursor: 'not-allowed', + background: theme.colors.neutral.backgroundDisabled, + }, + }, + }, + compoundVariants: [ + { + variants: { disabled: false, state: 'over' }, + style: { + background: theme.colors.neutral.backgroundHover, + cursor: 'copy', + }, + }, + ], + defaultVariants: { + state: 'default', + size: 'medium', + disabled: false, + }, +}) + +export const fileInput = style({ display: 'none' }) + +export const titleSmall = styleVariants({ + default: { + cursor: 'pointer', + }, + disabled: { + cursor: 'not-allowed', + }, +}) + +const buttonFileInputBase = style({ + display: 'flex', + gap: theme.space[1], + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', +}) + +export const buttonFileInput = styleVariants({ + default: [ + buttonFileInputBase, + { + cursor: 'pointer', + }, + ], + disabled: [ + buttonFileInputBase, + { + cursor: 'not-allowed', + }, + ], +}) + +export const overlayWrapper = style({ + height: 'fit-content', + width: 'fit-content', + position: 'relative', +}) + +const dropzoneOverlayBase = style({ + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}) + +export const dropzoneOverlay = styleVariants({ + over: [ + dropzoneOverlayBase, + { + borderRadius: theme.radii.default, + border: `1px dashed ${theme.colors.primary.borderStrong}`, + background: theme.colors.primary.background, + cursor: 'copy', + textAlign: 'center', + }, + ], + default: [ + dropzoneOverlayBase, + { + display: 'none', + }, + ], + page: [ + dropzoneOverlayBase, + { + borderRadius: theme.radii.default, + border: `1px dashed ${theme.colors.neutral.borderStrong}`, + background: theme.colors.primary.background, + cursor: 'copy', + textAlign: 'center', + }, + ], +}) + +const dropzoneOverlayDisabledOver = style({ + background: theme.colors.primary.backgroundDisabled, + border: `1px dashed ${theme.colors.primary.borderDisabled}`, +}) + +export const dropzoneOverlayDisabled = styleVariants({ + over: [dropzoneOverlayDisabledOver], + default: {}, + page: [dropzoneOverlayDisabledOver], +}) + +const fileViewerContainerBase = style({ + width: 'fit-content', + padding: theme.space[1], + borderRadius: theme.radii.default, +}) + +export const fileViewerContainer = styleVariants({ + error: [ + fileViewerContainerBase, + { + background: theme.colors.danger.background, + }, + ], + default: [ + fileViewerContainerBase, + { + background: theme.colors.neutral.backgroundWeak, + }, + ], +}) +export const fileViewerImageBase = style({ + width: theme.sizing[400], + height: theme.sizing[400], + objectFit: 'cover', + borderRadius: theme.radii.default, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}) + +export const fileViewerImage = styleVariants({ + error: [ + fileViewerImageBase, + { + background: theme.colors.danger.background, + }, + ], + default: [ + fileViewerImageBase, + { + background: theme.colors.primary.background, + }, + ], +}) diff --git a/packages/ui/src/components/FileInput/types.ts b/packages/ui/src/components/FileInput/types.ts new file mode 100644 index 0000000000..e485303151 --- /dev/null +++ b/packages/ui/src/components/FileInput/types.ts @@ -0,0 +1,100 @@ +import type { + CSSProperties, + Dispatch, + DragEvent, + ReactNode, + SetStateAction, +} from 'react' + +type ChildrenType = ReactNode | ((inputId: string) => ReactNode) + +export type FilesType = { + fileName: string + file: string + size: number + lastModified: number + type: string + loading?: boolean + error?: string +} + +/** + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types#types + */ +export type MimeType = + | 'application' + | 'audio' + | 'example' + | 'font' + | 'image' + | 'model' + | 'text' + | 'video' + +type LabelType = + | { label: string; 'aria-label'?: never } + | { label?: never; 'aria-label': string } + +/** + * Add the dropzone inside any content: when hovering, replace the content with the dropzone overlay + */ +type OverlayVariantProps = { + variant?: 'overlay' + /** Size of the dropzone. When set to small, the component can be used inline */ + size?: never + /** Main text to display in the dropzone */ + title: ReactNode + children: ChildrenType +} + +export type DropzoneVariantProps = { + variant?: 'dropzone' + /** Size of the dropzone. When set to small, the component can be used inline */ + size?: 'small' | 'medium' + children?: ChildrenType + /** Main text to display in the dropzone */ + title?: ChildrenType +} + +type ListTypeOverlay = { + list: true + variant: 'overlay' + listPosition?: 'top' | 'bottom' + listLimit?: { limit: number; overflowText: string } +} +type ListTypeDropZone = { + list: true + variant?: 'dropzone' + listPosition?: never + listLimit?: { limit: number; overflowText: string } +} + +type ListType = + | ListTypeOverlay + | ListTypeDropZone + | { list?: false; listPosition?: never; listLimit?: never } + +export type FileInputProps = { + style?: CSSProperties + className?: string + label?: string + labelDescription?: ReactNode + helper?: string + onDrop?: (event: DragEvent) => void + disabled?: boolean + accept?: HTMLInputElement['accept'] + onChangeFiles?: (files: FilesType[]) => void + defaultFiles?: FilesType[] + /** When set to true, multiple files can be added */ + multiple?: boolean + 'data-testid'?: string +} & (OverlayVariantProps | DropzoneVariantProps) & + LabelType & + ListType + +export type ListFilesProps = { + files: FilesType[] + setFiles: Dispatch> + onChangeFiles?: (files: FilesType[]) => void + listLimit?: { limit: number; overflowText: string } +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 1c4817d967..d202f73be4 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -20,6 +20,7 @@ export { Drawer } from './Drawer' export { EmptyState } from './EmptyState' export { Expandable } from './Expandable' export { ExpandableCard } from './ExpandableCard' +export { FileInput } from './FileInput' export { GlobalAlert } from './GlobalAlert' export { InfiniteScroll } from './InfiniteScroll' export { Key } from './Key'