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..2200305930 --- /dev/null +++ b/packages/ui/src/components/FileInput/FileInputProvider.tsx @@ -0,0 +1,26 @@ +import type { Dispatch, RefObject, SetStateAction } from 'react' +import { createContext, useContext } from 'react' +import type { FilesType } from './types' + +type FileInputContextType = + | { + disabled?: boolean + inputRef: RefObject + files: FilesType[] + setFiles: Dispatch> + onChangeFiles?: (files: FilesType[]) => void + } + | 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__/Bottom.stories.tsx b/packages/ui/src/components/FileInput/__stories__/Bottom.stories.tsx new file mode 100644 index 0000000000..7216d01220 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Bottom.stories.tsx @@ -0,0 +1,23 @@ +import type { StoryFn } from '@storybook/react-vite' +import { FileInput } from '..' + +export const Bottom: StoryFn = args => ( + +) + +Bottom.parameters = { + docs: { + description: { + story: + 'Add content outside of the container using prop `bottom`. It usually is a helper or `FileInput.List`.', + }, + }, +} 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..82862ba906 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Children.stories.tsx @@ -0,0 +1,100 @@ +import type { StoryFn } from '@storybook/react-vite' +import { useState } from 'react' +import { Avatar } from '../../Avatar' +import { Stack } from '../../Stack' +import { Text } from '../../Text' +import { FileInput } from '..' +import { hereText } from './styles.css' + +export const Children: StoryFn = args => { + const [image, setImage] = useState(undefined) + + return ( + + With inputId + } + disabled={args.disabled} + title={inputId => ( + + )} + 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 + + + )} + + With inputRef + setImage(files[0].file)} + title="dnd here" + variant="overlay" + > + {(_, inputRef) => + image ? ( + inputRef?.current?.click()} + shape="square" + upload + variant="image" + /> + ) : ( + inputRef?.current?.click()} + shape="square" + text="UV" + upload + variant="text" + /> + ) + } + + + ) +} +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. It is also possible to get the `ref` of the input the same way.', + }, + }, +} 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..f9c7b198cc --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Controlled.stories.tsx @@ -0,0 +1,49 @@ +import type { StoryFn } from '@storybook/react-vite' +import { useState } from 'react' +import { Button } from '../../Button' +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.length > 0 ? ( +
    + {files.map(file => ( +
  • {file.fileName}
  • + ))} +
+ ) : ( + ' none' + )} +
+ +
+ ) +} + +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..cb2d163b08 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/List.stories.tsx @@ -0,0 +1,97 @@ +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 => ( + + } + defaultFiles={defaultFile} + disabled={args.disabled} + label="type='dropzone'" + multiple + size="small" + title="Click or drag file here" + variant="dropzone" + /> + + + Type "ovelay" + + + + } + defaultFiles={defaultFile} + disabled={args.disabled} + label="With prop listLimit" + multiple + size="small" + title="Click or drag file here" + variant="dropzone" + /> + +) + +List.parameters = { + docs: { + description: { + story: + 'With sub-component `FileInput.List` it is possible to display all the drag&drop files added to the input. The size of each file is displayed and computed automatically from file.size (number, in byte). It is also possible to add a limit to the number of visible files in the list using prop `limit` & `limitText` (text to put in the "see all" button). To work properly, the list needs the `FileInputProvider`, so it is only usable in the context of the FileInputComponent. Use prop `bottom` to add it outside the fileInput container.', + }, + }, +} 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..d9914089fe --- /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 => ( + + } + defaultFiles={defaultFile} + disabled={args.disabled} + label="Multiple" + multiple + size="small" + title="Click or drag file here" + variant="dropzone" + /> + } + defaultFiles={defaultFile} + disabled={args.disabled} + label="Not multiple (default behavior)" + size="small" + title="Click or drag file here" + variant="dropzone" + /> + +) + +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..df283a36ae --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Overlay.stories.tsx @@ -0,0 +1,88 @@ +import type { StoryFn } from '@storybook/react-vite' +import { RebootIcon, SendIcon, UploadIcon } from '@ultraviolet/icons' +import type { Dispatch, RefObject, SetStateAction } from 'react' +import { useState } from 'react' +import { Button } from '../../Button' +import { Stack } from '../../Stack' +import { TextInput } from '../../TextInput' +import { FileInput } from '..' +import type { FilesType } from '../types' +import { promptInput, promptWrapper } from './styles.css' + +const Prompt = ({ + inputRef, + setFiles, +}: { + inputRef: RefObject + setFiles: Dispatch> +}) => ( + + + + + + + + + +) + +export const Overlay: StoryFn = args => { + const [files, setFiles] = useState([]) + + return ( + + + Some content (this is an overlay) + + setFiles(newFiles)} + title={ + + Drag file to this area to upload + + } + variant="overlay" + > + {(_, inputRef) => } + + + ) +} + +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..67e4336d8e --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/Template.stories.tsx @@ -0,0 +1,25 @@ +import type { StoryFn } from '@storybook/react-vite' +import { PlusIcon, UploadIcon } from '@ultraviolet/icons' +import { Stack } from '../../Stack' +import { FileInput } from '..' + +export const Template: StoryFn = args => ( + + + + + Add folder + + + + Upload + + + +) + +Template.args = { + 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..e51d72b845 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/index.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta } from '@storybook/react-vite' +import { FileInput } from '..' + +export default { + component: FileInput, + decorators: [StoryComponent => ], + subcomponents: { + 'FileInput.List': FileInput.List, + 'FileInput.Button': FileInput.Button, + }, + + title: 'Components/Data Entry/FileInput', +} as Meta + +export { Playground } from './Playground.stories' +export { DropzoneSize } from './DropzoneSize.stories' +export { Children } from './Children.stories' +export { Multiple } from './Multiple.stories' +export { Disabled } from './Disabled.stories' +export { Bottom } from './Bottom.stories' +export { Overlay } from './Overlay.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..75e3889446 --- /dev/null +++ b/packages/ui/src/components/FileInput/__stories__/styles.css.ts @@ -0,0 +1,22 @@ +import { theme } from '@ultraviolet/themes' +import { style } from '@vanilla-extract/css' + +export const hereText = style({ + cursor: 'pointer', + selectors: { + '&:hover': { + textDecoration: 'underline', + }, + }, +}) + +export const promptInput = style({ + width: 500, +}) + +export const promptWrapper = style({ + background: theme.colors.neutral.backgroundWeak, + border: `1px solid ${theme.colors.neutral.border}`, + padding: theme.space[3], + paddingBottom: theme.space[1], +}) 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..e0cf072d11 --- /dev/null +++ b/packages/ui/src/components/FileInput/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,2862 @@ +// 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`] = ` + +
+
+
+ +
+
+
+
+
+ +
+

+ 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 +

+
+
+ +
+
+
+ nodrag +
+
+
+
+
+ +`; + +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 bottom 1`] = ` + +
+
+
+
+
+ + + + + +

+

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

+

+
+
+
+
+
+`; + +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..bad6903751 --- /dev/null +++ b/packages/ui/src/components/FileInput/__tests__/index.test.tsx @@ -0,0 +1,310 @@ +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 with multiple and list - empty', () => { + const { asFragment } = renderWithTheme( + + + , + ) + expect(asFragment()).toMatchSnapshot() + }) + + test('renders correctly with bottom', () => { + 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, inputRef) => ( + <> + + + + )} + , + ) + + 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..f0862411b6 --- /dev/null +++ b/packages/ui/src/components/FileInput/components/Button.tsx @@ -0,0 +1,22 @@ +import type { ComponentProps } from 'react' +import { Button } from '../../Button' +import { useFileInput } from '../FileInputProvider' + +export const FileInputButton = ({ + children, + disabled, + ...props +}: ComponentProps) => { + const context = useFileInput() + const isDisabled = disabled || context.disabled + + return ( + + ) +} 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..65aae2ec62 --- /dev/null +++ b/packages/ui/src/components/FileInput/components/List.tsx @@ -0,0 +1,155 @@ +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 { useFileInput } from '../FileInputProvider' +import { formatFileSize, getMimeTypeType } from '../helpers' +import { fileInfo, fileViewerContainer, fileViewerImage } from '../styles.css' +import type { ListProps, 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 = ({ + limit, + textLimit, + prominence = 'default', +}: ListProps) => { + const [computedLimit, setLimit] = useState(limit) + const seeAllOnClick = () => { + setLimit(undefined) + } + const { files, setFiles, onChangeFiles } = useFileInput() + + return files.length > 0 ? ( + + {files.map((file, index) => { + if (!computedLimit || index < computedLimit) { + 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 + })} + {computedLimit && files.length > computedLimit ? ( + + ) : null} + + ) : 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..b4bf423122 --- /dev/null +++ b/packages/ui/src/components/FileInput/index.tsx @@ -0,0 +1,299 @@ +'use client' + +import { UploadIcon } from '@ultraviolet/icons' +import type { ChangeEvent, DragEvent } from 'react' +import { useEffect, useId, useRef, 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, + 'aria-label': ariaLabel, + defaultFiles, + onChangeFiles, + helper, + multiple = false, + bottom, + 'data-testid': dataTestid, +}: FileInputProps) => { + const [dragState, setDragState] = useState<'over' | 'default' | 'page'>( + 'default', + ) + const [files, setFiles] = useState(defaultFiles ?? []) + + const inputId = useId() + const inputRef = useRef(null) + + 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) + } + }, []) + + useEffect(() => { + if (defaultFiles) { + setFiles(defaultFiles) + } + }, [defaultFiles]) + + 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 ( + + +
+ +
+ {typeof children === 'function' + ? children(inputId, inputRef) + : children} +
event.preventDefault()} + onDrop={event => { + if (!disabled) { + onDrop?.(event) + manageDrop(event) + } + }} + style={style} + > + {title && + typeof title !== 'function' && + dragState !== 'default' ? ( + + {title} + + ) : null} +
+
+
+ {bottom ? ( + + {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, inputRef) : title} + + {typeof children === 'function' + ? children(inputId, inputRef) + : children} + + + {helper ? ( + + {helper} + + ) : null} + + {bottom ? ( + + {bottom} + + ) : null} + + + ) +} + +export const FileInput = Object.assign(FileInputBase, { + Button: FileInputButton, + List: ListFiles, +}) 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..ca23698f0a --- /dev/null +++ b/packages/ui/src/components/FileInput/styles.css.ts @@ -0,0 +1,168 @@ +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', + }, +}) + +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, + }, + ], + strong: [ + fileViewerContainerBase, + { + background: theme.colors.neutral.background, + }, + ], +}) +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, + }, + ], +}) + +export const fileInfo = style({ + maxWidth: 264, + textWrap: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}) diff --git a/packages/ui/src/components/FileInput/types.ts b/packages/ui/src/components/FileInput/types.ts new file mode 100644 index 0000000000..dae0830779 --- /dev/null +++ b/packages/ui/src/components/FileInput/types.ts @@ -0,0 +1,84 @@ +import type { CSSProperties, DragEvent, ReactNode, RefObject } from 'react' + +type ChildrenType = + | ReactNode + | (( + inputId: string, + inputRef: RefObject, + ) => 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 + bottom?: never +} + +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 + /** Content to add outside the container */ + bottom?: ReactNode +} + +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 + +type LimitType = + | { limit: number; textLimit: string } + | { limit?: never; textLimit?: never } + +export type ListProps = { + prominence?: 'default' | 'strong' +} & LimitType 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'