diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index f80fabf1d..1585e632d 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -368,4 +368,10 @@ export const rows: Rows = [ stage: '🔵 experimental', planned: 'Q3 2026', }, + { + component: 'FileUpload', + status: '✅ Done', + stage: '🔵 experimental', + planned: 'Q3 2026', + }, ]; diff --git a/.storybook/public/files/security-scan-report.json b/.storybook/public/files/security-scan-report.json new file mode 100644 index 000000000..9f7744d43 --- /dev/null +++ b/.storybook/public/files/security-scan-report.json @@ -0,0 +1,5 @@ +{ + "scanId": "demo-scan-001", + "status": "completed", + "findings": 3 +} diff --git a/packages/components/src/components/FileUpload/FileUpload.mdx b/packages/components/src/components/FileUpload/FileUpload.mdx new file mode 100644 index 000000000..3474c0d62 --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUpload.mdx @@ -0,0 +1,142 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './FileUpload.stories'; + + + +# FileUpload + + + +Allows users to upload files to the product. + +## Import + +```tsx +import { FileUpload } from '@koobiq/react-components'; +``` + +## Usage + + + +## Props + + + +## Data model + +Pass `items` and a render function for a dynamic collection. Pass +`FileUpload.Item` elements as children for a static collection. Keep the native +file on each item and pass it to `FileUpload.Item` to enable validation. + +## Long file names + +Long item names are truncated in the middle, keeping the end of the name and +its extension visible. Their full text is shown in a tooltip. + + + +## Label and help text + +Use `label` and `caption` to describe the field. + + + +## Single file + +FileUpload accepts one file by default. + + + +## Multiple files + +Use `allowsMultiple` to add several files. + + + +## Duplicate files + +Filter duplicate files in `onAdd` when the collection requires it. + + + +## Selecting a folder or files + +Use `allowed` to accept files, folders, or both. Folder files include their +`relativePath` and can be grouped into one collection item. + + + +## Compact + +Use `size="compact"` for a smaller empty state. + + + +## Scrolling + +Use `slotProps` to limit the list height. + + + +## Progress + +Render upload progress inside the item row. + + + +## Validation + +Use `accept` and `maxFileSize` for standard checks, and `validate` for custom +synchronous checks. Invalid files remain in the collection. + +Pass text or JSX to `errorMessage` to replace generated errors. Pass a function +to format the `ValidationResult`, or `null` to hide the error text. + + + +## Disabled + +Use `isDisabled` to disable the whole component. + + + +## Invalid + +Use `isInvalid` with `errorMessage` for a field-level validation error. + + + +## Drop zone target + +By default, the drop area is inside the component. +Pass an element ref to `dropzoneTarget` to use another container, +or `"fullscreen"` to make the whole screen the drop area. + + + +## Server file + +Keep the native `File` in local items and append it to `FormData` on submit. + + + +## Customization + +Use `messages` to change standard text. Use `slotProps` for field text, list +parts, and the drop overlay. Use +`renderEmptyState` to replace the large empty state. + + + +## Accessibility + +Provide `label`, `aria-label`, or `aria-labelledby` and a `textValue` for each +item. Label, caption, and error text are associated with the upload area. After +removal, focus moves to the next file or the browse trigger. diff --git a/packages/components/src/components/FileUpload/FileUpload.module.css b/packages/components/src/components/FileUpload/FileUpload.module.css new file mode 100644 index 000000000..7483b8bd4 --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUpload.module.css @@ -0,0 +1,102 @@ +.base { + --file-upload-container-border-color: var(--kbq-line-contrast-fade); + --file-upload-container-background: var(--kbq-background-bg); + --file-upload-dropzone-icon-color: var(--kbq-icon-contrast-fade); + --file-upload-upload-icon-color: var(--kbq-icon-contrast); + --file-upload-left-icon-color: var(--kbq-icon-contrast-fade); + --file-upload-text-color: var(--kbq-foreground-contrast); + --file-upload-caption-color: var(--file-upload-text-color); + --file-upload-file-size-color: var(--kbq-foreground-contrast-secondary); + --file-upload-footer-border-color: var(--file-upload-container-border-color); + --file-upload-border-width: var(--kbq-size-border-width); + --file-upload-border-radius: var(--kbq-size-s); + --file-upload-single-min-block-size: 44px; + --file-upload-single-padding-block: var(--kbq-size-m); + --file-upload-single-padding-inline: var(--kbq-size-l); + --file-upload-list-min-block-size: 120px; + --file-upload-multiple-min-block-size: 192px; + --file-upload-multiple-min-inline-size: 320px; + --file-upload-grid-cell-padding-inline: var(--kbq-size-s); + --file-upload-grid-cell-gap: var(--kbq-size-s); + --file-upload-compact-padding-block: var(--kbq-size-m); + --file-upload-compact-padding-inline: var(--kbq-size-l); + + min-inline-size: 0; + inline-size: 100%; +} + +.base[data-invalid][data-empty]:not([data-disabled]) { + --file-upload-container-border-color: var(--kbq-line-error); + --file-upload-container-background: var(--kbq-background-error-less); + --file-upload-dropzone-icon-color: var(--kbq-icon-error); + --file-upload-upload-icon-color: var(--kbq-icon-error); + --file-upload-caption-color: var(--kbq-foreground-error); +} + +.base[data-invalid][data-empty]:not([data-disabled], [data-multiple]) { + --file-upload-left-icon-color: var(--kbq-icon-error); + --file-upload-text-color: var(--kbq-foreground-error); + --file-upload-file-size-color: var(--kbq-foreground-error); +} + +.base[data-disabled] { + --file-upload-container-border-color: var(--kbq-states-line-disabled); + --file-upload-container-background: var(--kbq-states-background-disabled); + --file-upload-dropzone-icon-color: var(--kbq-states-icon-disabled); + --file-upload-upload-icon-color: var(--kbq-states-icon-disabled); + --file-upload-left-icon-color: var(--kbq-states-icon-disabled); + --file-upload-text-color: var(--kbq-states-foreground-disabled); + --file-upload-file-size-color: var(--kbq-states-foreground-disabled); + --file-upload-footer-border-color: var(--kbq-states-line-disabled); +} + +.base:not([data-disabled], [data-invalid][data-empty]) + [data-slot='file-upload-control'][data-drop-target] { + --file-upload-container-border-color: var(--kbq-line-theme-fade); + --file-upload-container-background: var(--kbq-background-theme-fade); + --file-upload-footer-border-color: var(--kbq-line-theme-fade); +} + +.base[data-disabled] [data-slot='file-upload-control'] { + pointer-events: none; +} + +.base:not([data-multiple]) [data-slot='file-upload-control'] { + min-block-size: var(--file-upload-single-min-block-size); +} + +.base:not([data-multiple])[data-empty] [data-slot='file-upload-control'] { + padding-block: var(--file-upload-single-padding-block); + padding-inline: var(--file-upload-single-padding-inline); +} + +.base[data-multiple]:not([data-list-empty]) [data-slot='file-upload-control'] { + min-block-size: var(--file-upload-multiple-min-block-size); +} + +.base[data-multiple][data-empty]:not([data-list-empty])[data-size='default'] + [data-slot='file-upload-control'] { + align-items: center; + justify-content: center; + min-inline-size: var(--file-upload-multiple-min-inline-size); +} + +.base[data-multiple][data-list-empty] [data-slot='file-upload-control'] { + padding-block: var(--file-upload-compact-padding-block); + padding-inline: var(--file-upload-compact-padding-inline); +} + +.body { + min-inline-size: 0; + inline-size: 100%; +} + +.body > :first-child + * { + margin: 0; + margin-block-start: var(--kbq-size-xs); +} + +.body > :first-child + * + * { + margin: 0; + margin-block-start: var(--kbq-size-xxs); +} diff --git a/packages/components/src/components/FileUpload/FileUpload.stories.tsx b/packages/components/src/components/FileUpload/FileUpload.stories.tsx new file mode 100644 index 000000000..c54e7ee80 --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUpload.stories.tsx @@ -0,0 +1,830 @@ +import { useRef, useState } from 'react'; + +import { type Key, useBoolean } from '@koobiq/react-core'; +import { + IconFolder16, + IconBoxArchive24, + IconCircleCheck16, +} from '@koobiq/react-icons'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Button } from '../Button'; +import { FlexBox } from '../FlexBox'; +import { Form } from '../Form'; +import { spacing } from '../layout'; +import { ProgressSpinner } from '../ProgressSpinner'; +import { Toggle } from '../Toggle'; +import { Typography } from '../Typography'; + +import { FileUpload } from './FileUpload'; +import type { FileUploadFile, FileUploadProps } from './types'; + +const meta = { + title: 'Components/FileUpload', + component: FileUpload, + subcomponents: { + 'FileUpload.Empty': FileUpload.Empty, + 'FileUpload.EmptyIcon': FileUpload.EmptyIcon, + 'FileUpload.EmptyTitle': FileUpload.EmptyTitle, + 'FileUpload.EmptyDescription': FileUpload.EmptyDescription, + 'FileUpload.Item': FileUpload.Item, + 'FileUpload.ItemIcon': FileUpload.ItemIcon, + 'FileUpload.ItemContent': FileUpload.ItemContent, + 'FileUpload.ItemName': FileUpload.ItemName, + 'FileUpload.ItemSize': FileUpload.ItemSize, + 'FileUpload.RemoveButton': FileUpload.RemoveButton, + 'FileUpload.Trigger': FileUpload.Trigger, + }, + parameters: { + layout: 'centered', + }, + tags: ['status:new', 'date:2026-07-07'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; + +type FileUploadItemData = { + id: Key; + name?: string; + size?: number; + isLoading?: boolean; + isUploaded?: boolean; + progress?: number; + isDisabled?: boolean; + kind?: 'file' | 'folder'; + file?: FileUploadFile; + files?: FileUploadFile[]; +}; + +type Story = StoryObj>; + +const makeFile = ( + name: string, + size: number, + type = 'application/octet-stream' +): FileUploadFile => + Object.assign(new File([new Uint8Array(size)], name, { type }), { + relativePath: '', + }); + +const toItem = (file: FileUploadFile): FileUploadItemData => ({ + id: crypto.randomUUID(), + kind: 'file', + name: file.name, + size: file.size, + file, +}); + +const makeItem = ( + name: string, + size: number, + type?: string +): FileUploadItemData => toItem(makeFile(name, size, type)); + +const removeById = (items: FileUploadItemData[], id: Key) => + items.filter((item) => item.id !== id); + +export const Base: Story = { + render: function Render(args) { + const [items, setItems] = useState([]); + + return ( + setItems(files.map(toItem))} + onRemove={(id) => setItems((prev) => removeById(prev, id))} + {...args} + > + {(item) => ( + + + + {item.name} + + + + )} + + ); + }, +}; + +export const LongFileName: Story = { + render: function Render(args) { + const name = + 'incident-response-evidence-security-scan-report-for-production-environment.json'; + + const file = makeFile(name, 10000, 'application/json'); + + return ( + + + + + {name} + {file.size} + + + + + ); + }, +}; + +export const WithLabel: Story = { + render: function Render(args) { + const [items, setItems] = useState([]); + + return ( + setItems(files.map(toItem))} + onRemove={(id) => setItems((prev) => removeById(prev, id))} + {...args} + > + {(item) => ( + + + + {item.name} + {item.size} + + + + )} + + ); + }, +}; + +export const Single: Story = { + render: function Render(args) { + const [items, setItems] = useState([]); + + return ( + setItems(files.map(toItem))} + onRemove={(id) => setItems((prev) => removeById(prev, id))} + {...args} + > + {(item) => ( + + + + {item.name} + + + + )} + + ); + }, +}; + +export const Multiple: Story = { + render: function Render(args) { + const [items, setItems] = useState([]); + + return ( + setItems((prev) => removeById(prev, id))} + onAdd={(files) => setItems((prev) => [...prev, ...files.map(toItem)])} + allowsMultiple + {...args} + > + {(item) => ( + + + + {item.name} + {item.size} + + + + )} + + ); + }, +}; + +export const WithoutDuplicates: Story = { + render: function Render(args) { + const getFileFingerprint = (file: File) => + `${file.name}-${file.lastModified}-${file.size}`; + + const [items, setItems] = useState([]); + + return ( + setItems((prev) => removeById(prev, id))} + onAdd={(files) => + setItems((prev) => { + const fingerprints = new Set( + prev.flatMap((item) => + item.file ? [getFileFingerprint(item.file)] : [] + ) + ); + + const added = files.flatMap((file) => { + const fingerprint = getFileFingerprint(file); + + if (fingerprints.has(fingerprint)) return []; + + fingerprints.add(fingerprint); + + return [toItem(file)]; + }); + + return [...prev, ...added]; + }) + } + allowsMultiple + {...args} + > + {(item) => ( + + + + {item.name} + {item.size} + + + + )} + + ); + }, +}; + +export const Allowed: Story = { + name: 'Selecting a folder or files', + render: function Render(args) { + const toGroupedItems = (files: FileUploadFile[]): FileUploadItemData[] => { + const folders = new Map(); + const items: FileUploadItemData[] = []; + + files.forEach((file) => { + const folderName = file.relativePath.split('/')[0]; + + if (folderName) { + folders.set(folderName, [...(folders.get(folderName) ?? []), file]); + } else { + items.push(toItem(file)); + } + }); + + folders.forEach((folderFiles, name) => { + items.push({ + id: crypto.randomUUID(), + kind: 'folder', + name, + files: folderFiles, + size: folderFiles.reduce((total, file) => total + file.size, 0), + }); + }); + + return items; + }; + + const [items, setItems] = useState([]); + + return ( + setItems((prev) => removeById(prev, id))} + onAdd={(files) => + setItems((prev) => [...prev, ...toGroupedItems(files)]) + } + allowsMultiple + {...args} + > + {(item) => ( + + + {item.kind === 'folder' ? : undefined} + + + {item.name} + {item.size} + + + + )} + + ); + }, +}; + +export const Compact: Story = { + render: function Render(args) { + const [items, setItems] = useState([]); + + return ( + setItems((prev) => removeById(prev, id))} + onAdd={(files) => setItems((prev) => [...prev, ...files.map(toItem)])} + allowsMultiple + {...args} + > + {(item) => ( + + + + {item.name} + {item.size} + + + + )} + + ); + }, +}; + +export const Scrollable: Story = { + render: function Render(args) { + const [items, setItems] = useState( + Array.from({ length: 8 }, (_, index) => + makeItem(`document_${index + 1}.pdf`, 148909 * (index + 1)) + ) + ); + + return ( + setItems((prev) => [...prev, ...files.map(toItem)])} + onRemove={(id) => setItems((prev) => removeById(prev, id))} + allowsMultiple + {...args} + > + {(item) => ( + + + + {item.name} + {item.size} + + + + )} + + ); + }, +}; + +export const UploadProgress: Story = { + render: function Render(args) { + const [items, setItems] = useState([]); + + function simulateUpload(onProgress: (progress: number) => void) { + let progress = 0; + + const interval = window.setInterval(() => { + progress += 20; + onProgress(progress); + + if (progress >= 100) { + window.clearInterval(interval); + } + }, 400); + } + + return ( + { + const added = files.map((file) => ({ + ...toItem(file), + isLoading: true, + progress: 0, + })); + + setItems((prev) => [...prev, ...added]); + + added.forEach((item) => { + simulateUpload((progress) => { + setItems((prev) => + prev.map((current) => + current.id === item.id + ? { + ...current, + isLoading: progress < 100, + progress: progress < 100 ? progress : undefined, + } + : current + ) + ); + }); + }); + }} + onRemove={(id) => setItems((prev) => removeById(prev, id))} + allowsMultiple + {...args} + > + {(item) => ( + + + {item.isLoading ? ( + + ) : undefined} + + + {item.name} + {item.size} + + + + )} + + ); + }, +}; + +export const Validation: Story = { + render: function Render(args) { + const MAX_SIZE = 150 * 1024; + + const [items, setItems] = useState(() => + [ + makeFile('notes.txt', 1000, 'text/plain'), + makeFile('large-image.png', MAX_SIZE + 1, 'image/png'), + makeFile('empty-image.png', 0, 'image/png'), + makeFile('valid-image.png', 1000, 'image/png'), + ].map(toItem) + ); + + return ( + + file.size === 0 ? 'Empty files are not allowed' : null + } + errorMessage={({ validationErrors }) => ( + + {validationErrors.map((error, index) => ( + {error} + ))} + + )} + onAdd={(files) => setItems((prev) => [...prev, ...files.map(toItem)])} + onRemove={(id) => setItems((prev) => removeById(prev, id))} + renderEmptyState={() => ( + + + Upload images + + Drag images here or{' '} + select images + + + )} + allowsMultiple + {...args} + > + {(item) => ( + + + + {item.name} + {item.size} + + + + )} + + ); + }, +}; + +export const Disabled: Story = { + render: function Render(args) { + return ( + + {(item) => ( + + + + {item.name} + {item.size !== undefined && ( + {item.size} + )} + + + + )} + + ); + }, +}; + +export const Invalid: Story = { + render: function Render(args) { + return ( + + {(item) => ( + + + + {item.name} + {item.size} + + + + )} + + ); + }, +}; + +export const DropzoneTarget: Story = { + name: 'Drop zone target', + render: function Render(args) { + const dropTargetRef = useRef(null); + const [isFullscreen, { set: setFullscreen }] = useBoolean(false); + const [items, setItems] = useState([]); + + return ( +
+ + Activate fullscreen dropzone + + + Lorem ipsum dolor sit amet, consectetur adipisicing elit. Illum iure + molestiae perferendis provident quod repudiandae. Ab fugiat itaque + nihil officia. + + setItems((prev) => [...prev, ...files.map(toItem)])} + onRemove={(id) => setItems((prev) => removeById(prev, id))} + allowsMultiple + {...args} + > + {(item) => ( + + + + {item.name} + {item.size} + + + + )} + +
+ ); + }, +}; + +export const ServerFile: Story = { + render: function Render(args) { + // Mock POST /api/files. + const uploadFilesToServer = async (formData: FormData) => { + await new Promise((resolve) => setTimeout(resolve, 800)); + + return formData.getAll('files'); + }; + + const [isUploading, setUploading] = useState(false); + const [items, setItems] = useState([]); + + return ( +
{ + event.preventDefault(); + + const localFiles = items.flatMap((item) => + item.file && !item.isUploaded + ? [{ id: item.id, file: item.file }] + : [] + ); + + if (localFiles.length === 0) return; + + const formData = new FormData(); + + localFiles.forEach(({ file }) => + formData.append('files', file, file.name) + ); + + const uploadingIds = new Set(localFiles.map(({ id }) => id)); + + setItems((current) => + current.map((item) => + uploadingIds.has(item.id) ? { ...item, isLoading: true } : item + ) + ); + + setUploading(true); + + try { + await uploadFilesToServer(formData); + + setItems((current) => + current.map((item) => + uploadingIds.has(item.id) + ? { ...item, isLoading: false, isUploaded: true } + : item + ) + ); + } finally { + setUploading(false); + } + }} + > + setItems((prev) => [...prev, ...files.map(toItem)])} + onRemove={(id) => setItems((prev) => removeById(prev, id))} + allowsMultiple + {...args} + > + {(item) => ( + + + {item.isLoading ? ( + + ) : item.isUploaded ? ( + + ) : undefined} + + + {item.name} + {item.size} + + + + )} + + + + +
+ ); + }, +}; + +export const CustomContent: Story = { + render: function Render(args) { + const [items, setItems] = useState([]); + + return ( + setItems((prev) => [...prev, ...files.map(toItem)])} + onRemove={(id) => setItems((prev) => removeById(prev, id))} + renderEmptyState={() => ( + + + + + + + + )} + {...args} + > + {(item) => ( + + + + {item.name} + {item.size} + + + + )} + + ); + }, +}; diff --git a/packages/components/src/components/FileUpload/FileUpload.test.tsx b/packages/components/src/components/FileUpload/FileUpload.test.tsx new file mode 100644 index 000000000..1af420dc5 --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUpload.test.tsx @@ -0,0 +1,1810 @@ +import { createRef, useState } from 'react'; +import type { ReactNode } from 'react'; + +import type { Key, ValidationResult } from '@koobiq/react-core'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ProgressSpinner } from '../ProgressSpinner'; +import { Provider } from '../Provider'; + +import { readDroppedFiles } from './hooks'; +import { FileUpload } from './index'; +import type { FileUploadFile, FileUploadProps } from './index'; + +const ROOT_TEST_ID = 'file-upload'; + +type FileUploadItemData = { + id: Key; + name?: ReactNode; + size?: number; + isLoading?: boolean; + progress?: number; + errorMessage?: ReactNode; + isDisabled?: boolean; + file?: FileUploadFile; +}; + +const makeFile = (name: string, content = 'stub') => + new File([content], name, { type: 'text/plain' }); + +const makeSizedFile = (name: string, size: number, type = 'text/plain') => + new File([new Uint8Array(size)], name, { type }) as FileUploadFile; + +const makeItem = (name: string, size?: number): FileUploadItemData => ({ + id: name, + name, + size, +}); + +const toItem = (file: FileUploadFile): FileUploadItemData => ({ + id: file.name, + name: file.name, + size: file.size, + file, +}); + +const renderItem = (item: FileUploadItemData) => ( + + + {item.isLoading ? ( + + ) : undefined} + + + {item.name} + {item.size !== undefined && ( + {item.size} + )} + {item.errorMessage} + + + +); + +const getFileInput = () => + document.querySelector('input[type="file"]') as HTMLInputElement; + +const getDropTarget = () => screen.getByRole('group', { name: 'upload' }); + +const getDropOverlay = () => document.querySelector('[data-slot="overlay"]'); + +type TestFileUploadProps = Omit< + FileUploadProps, + 'items' | 'children' | 'onAdd' | 'onRemove' +> & { + initialItems?: FileUploadItemData[]; + onAdd?: (files: File[]) => void; + onRemove?: (id: Key) => void; +}; + +function TestFileUpload(props: TestFileUploadProps) { + const { + onAdd, + onRemove, + initialItems = [], + allowsMultiple = false, + ...other + } = props; + + const [items, setItems] = useState(initialItems); + + return ( + { + onAdd?.(files); + + setItems((prev) => + allowsMultiple + ? [...prev, ...files.map(toItem)] + : files.slice(0, 1).map(toItem) + ); + }} + onRemove={(id) => { + onRemove?.(id); + setItems((prev) => prev.filter((item) => item.id !== id)); + }} + {...other} + > + {renderItem} + + ); +} + +const renderComponent = (props: TestFileUploadProps = {}) => + render(); + +describe('FileUpload', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('should accept a ref', () => { + const ref = createRef(); + + render( + + {renderItem} + + ); + + expect(ref.current).toBe(screen.getByTestId(ROOT_TEST_ID)); + expect(ref.current).toHaveAttribute('data-slot', 'form-field'); + }); + + it('should merge a custom class name with the default ones', () => { + renderComponent({ className: 'foo' }); + + expect(screen.getByTestId(ROOT_TEST_ID)).toHaveClass('foo'); + }); + + it('should apply custom styles', () => { + renderComponent({ style: { padding: 20 } }); + + expect(screen.getByTestId(ROOT_TEST_ID)).toHaveStyle({ padding: '20px' }); + }); + + it('connects the label, caption and error message to the upload control', () => { + render( + + {renderItem} + + ); + + const control = screen.getByRole('group', { name: /Evidence/ }); + const label = screen.getByText('Evidence'); + const caption = screen.getByText('PNG or PDF, up to 10 MB'); + const error = screen.getByText('Select at least one file'); + + expect(label).toHaveAttribute('id'); + expect(caption).toHaveAttribute('id'); + expect(error).toHaveAttribute('id'); + expect(label.id).not.toBe(''); + expect(caption.id).not.toBe(''); + expect(error.id).not.toBe(''); + expect(control).toHaveAttribute('aria-labelledby', label.id); + expect(control).toHaveAttribute('aria-invalid', 'true'); + expect(control).toHaveAttribute('aria-describedby'); + expect(control.getAttribute('aria-describedby')).toContain(caption.id); + expect(control.getAttribute('aria-describedby')).toContain(error.id); + }); + + it('does not display the field error while valid', () => { + render( + + {renderItem} + + ); + + const control = screen.getByRole('group', { name: 'Evidence' }); + + expect(screen.queryByText('Select a file')).not.toBeInTheDocument(); + expect(control).not.toHaveAttribute('aria-invalid'); + expect(control).not.toHaveAttribute('aria-describedby'); + }); + + it('supports field layout and slot props', () => { + const labelRef = createRef(); + const captionRef = createRef(); + const errorRef = createRef(); + + render( + + {renderItem} + + ); + + const root = screen.getByTestId(ROOT_TEST_ID); + + expect(root).toHaveAttribute('data-label-placement', 'side'); + expect(root).toHaveAttribute('data-label-align', 'end'); + expect(root).toHaveAttribute('data-required'); + expect(labelRef.current).toHaveClass('custom-label'); + expect(labelRef.current).toHaveTextContent('Evidence *'); + expect(captionRef.current).toHaveClass('custom-caption'); + expect(errorRef.current).toHaveClass('custom-error'); + }); + + it('does not include the label, caption or error in the drop target', () => { + render( + + {renderItem} + + ); + + const control = screen.getByRole('group', { name: 'Evidence' }); + const dataTransfer = createDataTransfer([dropFile('a.txt')]); + + for (const element of [ + screen.getByText('Evidence'), + screen.getByText('Caption'), + screen.getByText('Error'), + ]) { + fireEvent.dragEnter(element, { dataTransfer }); + expect(control).not.toHaveAttribute('data-drop-target'); + } + + fireEvent.dragEnter(control, { dataTransfer }); + expect(control).toHaveAttribute('data-drop-target'); + }); + + it('renders the browse link when empty', () => { + renderComponent(); + + expect(screen.getByText('select a file')).toBeInTheDocument(); + }); + + it('does not add a hidden drop button or a root tab stop', () => { + renderComponent(); + + const root = screen.getByTestId(ROOT_TEST_ID); + + expect(root).not.toHaveAttribute('tabindex'); + expect(root.querySelector('button')).not.toBeInTheDocument(); + }); + + it('renders separate file and folder browse links in mixed mode', () => { + renderComponent({ allowed: 'mixed', allowsMultiple: true }); + + expect(screen.getByText('select files')).toBeInTheDocument(); + expect(screen.getByText('a folder')).toBeInTheDocument(); + expect(document.querySelectorAll('input[type="file"]')).toHaveLength(2); + }); + + it('allows the folder picker to return every file in single mode', () => { + renderComponent({ allowed: 'folder' }); + + const input = getFileInput(); + + expect(input).toHaveAttribute('webkitdirectory'); + expect(input).toHaveAttribute('multiple'); + }); + + it('normalizes the folder picker path to relativePath', async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + const file = makeFile('nested.txt'); + + Object.defineProperty(file, 'webkitRelativePath', { + value: 'folder/nested.txt', + }); + + renderComponent({ allowed: 'folder', onAdd }); + + await user.upload(getFileInput(), file); + + expect(onAdd).toHaveBeenCalledWith([file]); + expect(file).toHaveProperty('relativePath', 'folder/nested.txt'); + }); + + it('calls onAdd with selected files and renders the external item update', async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + const file = makeFile('hello.txt'); + + renderComponent({ onAdd }); + + await user.upload(getFileInput(), file); + + expect(onAdd).toHaveBeenCalledWith([file]); + expect(file).toHaveProperty('relativePath', ''); + + expect( + Object.prototype.propertyIsEnumerable.call(file, 'relativePath') + ).toBe(false); + + expect(screen.getByText('hello.txt')).toBeInTheDocument(); + expect(screen.queryByText('select a file')).not.toBeInTheDocument(); + }); + + it('passes picker files to onAdd regardless of accept', async () => { + const user = userEvent.setup({ applyAccept: false }); + const onAdd = vi.fn(); + const image = new File(['image'], 'image.png', { type: 'image/png' }); + const text = makeFile('notes.txt'); + + renderComponent({ + accept: ['image/*'], + allowsMultiple: true, + onAdd, + }); + + await user.upload(getFileInput(), [image, text]); + + expect(onAdd).toHaveBeenCalledWith([image, text]); + }); + + it('uses the upload control as the default drop target', () => { + renderComponent(); + + const root = screen.getByTestId(ROOT_TEST_ID); + const dropTarget = getDropTarget(); + const child = screen.getByText('select a file'); + const dataTransfer = createDataTransfer([dropFile('a.txt')]); + + fireEvent.dragEnter(dropTarget, { dataTransfer }); + fireEvent.dragEnter(child, { dataTransfer }); + fireEvent.dragLeave(child, { dataTransfer }); + + expect(dropTarget).toHaveAttribute('data-drop-target'); + expect(root).not.toHaveAttribute('data-drop-target'); + expect(getDropOverlay()).not.toBeInTheDocument(); + + fireEvent.dragLeave(dropTarget, { dataTransfer }); + + expect(dropTarget).not.toHaveAttribute('data-drop-target'); + }); + + it('uses an external element as the only drop target', async () => { + const targetRef = createRef(); + const onAdd = vi.fn(); + + render( + <> +
+ + + ); + + const root = screen.getByTestId(ROOT_TEST_ID); + const target = screen.getByTestId('external-target'); + const file = makeFile('external.txt'); + const dataTransfer = createDataTransfer([dropFile(file)]); + + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue({ + top: 10, + left: 20, + width: 300, + height: 200, + } as DOMRect); + + fireEvent.dragEnter(root, { dataTransfer }); + + expect(root).not.toHaveAttribute('data-drop-target'); + expect(target).not.toHaveAttribute('data-drop-target'); + + fireEvent.dragEnter(target, { dataTransfer }); + + expect(target).toHaveAttribute('data-drop-target'); + expect(root).not.toHaveAttribute('data-drop-target'); + + expect(getDropOverlay()).toHaveStyle({ + top: '10px', + left: '20px', + width: '300px', + height: '200px', + }); + + expect(getDropOverlay()).not.toHaveAttribute('data-fullscreen'); + + fireEvent.drop(target, { dataTransfer }); + + await waitFor(() => expect(onAdd).toHaveBeenCalledWith([file])); + expect(target).not.toHaveAttribute('data-drop-target'); + expect(getDropOverlay()).not.toBeInTheDocument(); + }); + + it('renders non-interactive empty content in the external drop overlay', () => { + const targetRef = createRef(); + const overlayRef = createRef(); + + render( + <> +
+ + {renderItem} + + + ); + + fireEvent.dragEnter(screen.getByTestId('external-target'), { + dataTransfer: createDataTransfer([dropFile('a.txt')]), + }); + + expect(document.getElementById('drop-overlay')).toHaveTextContent( + 'Drop files now' + ); + + expect(document.getElementById('drop-overlay')).toHaveClass( + 'custom-overlay' + ); + + expect(overlayRef.current).toBe(document.getElementById('drop-overlay')); + + expect( + getDropOverlay()?.querySelector('input[type="file"]') + ).not.toBeInTheDocument(); + }); + + it('covers the viewport in fullscreen mode', () => { + const dataTransfer = createDataTransfer([dropFile('a.txt')]); + + renderComponent({ dropzoneTarget: 'fullscreen' }); + + fireEvent.dragEnter(document.documentElement, { dataTransfer }); + + expect(document.documentElement).toHaveAttribute('data-drop-target'); + expect(getDropOverlay()).toHaveAttribute('data-fullscreen'); + + expect(getDropOverlay()).toHaveStyle({ + top: '0px', + left: '0px', + width: `${window.innerWidth}px`, + height: `${window.innerHeight}px`, + }); + + fireEvent.dragLeave(document.documentElement, { dataTransfer }); + + expect(document.documentElement).not.toHaveAttribute('data-drop-target'); + expect(getDropOverlay()).not.toBeInTheDocument(); + }); + + it('moves native listeners when the external target changes', () => { + const firstTargetRef = createRef(); + const secondTargetRef = createRef(); + + const { rerender } = render( + <> +
+
+ + + ); + + const firstTarget = screen.getByTestId('first-target'); + const secondTarget = screen.getByTestId('second-target'); + const dataTransfer = createDataTransfer([dropFile('a.txt')]); + + fireEvent.dragEnter(firstTarget, { dataTransfer }); + expect(firstTarget).toHaveAttribute('data-drop-target'); + expect(getDropOverlay()).toBeInTheDocument(); + + rerender( + <> +
+
+ + + ); + + expect(firstTarget).not.toHaveAttribute('data-drop-target'); + expect(getDropOverlay()).not.toBeInTheDocument(); + + fireEvent.dragEnter(secondTarget, { dataTransfer }); + expect(secondTarget).toHaveAttribute('data-drop-target'); + expect(getDropOverlay()).toBeInTheDocument(); + }); + + it('limits dropped files in single mode and keeps all in multiple mode', async () => { + const singleOnAdd = vi.fn(); + const multipleOnAdd = vi.fn(); + const first = makeFile('first.txt'); + const second = makeFile('second.txt'); + + const { unmount } = renderComponent({ onAdd: singleOnAdd }); + let dropTarget = getDropTarget(); + let dataTransfer = createDataTransfer([dropFile(first), dropFile(second)]); + + fireEvent.drop(dropTarget, { dataTransfer }); + + await waitFor(() => expect(singleOnAdd).toHaveBeenCalledWith([first])); + + unmount(); + renderComponent({ allowsMultiple: true, onAdd: multipleOnAdd }); + dropTarget = getDropTarget(); + dataTransfer = createDataTransfer([dropFile(first), dropFile(second)]); + + fireEvent.drop(dropTarget, { dataTransfer }); + + await waitFor(() => + expect(multipleOnAdd).toHaveBeenCalledWith([first, second]) + ); + }); + + it('adds files from a dropped nested folder', async () => { + const onAdd = vi.fn(); + const first = makeFile('a.txt'); + const second = makeFile('b.txt'); + + renderComponent({ allowed: 'folder', allowsMultiple: true, onAdd }); + + const dataTransfer = createDataTransfer([ + dropDirectory('outer', [ + fileEntry(first), + directoryEntry('inner', [fileEntry(second)]), + ]), + ]); + + fireEvent.drop(getDropTarget(), { dataTransfer }); + + await waitFor(() => expect(onAdd).toHaveBeenCalledWith([first, second])); + expect(first).toHaveProperty('relativePath', 'outer/a.txt'); + expect(second).toHaveProperty('relativePath', 'outer/inner/b.txt'); + }); + + it('applies allowed mode to dropped files and folders', async () => { + const looseFile = makeFile('loose.txt'); + const folderFile = makeFile('nested.txt'); + + const dataTransfer = createDataTransfer([ + dropFile(looseFile), + dropDirectory('folder', [fileEntry(folderFile)]), + ]); + + const onAdd = vi.fn(); + + const { unmount } = renderComponent({ + allowed: 'file', + allowsMultiple: true, + onAdd, + }); + + fireEvent.drop(getDropTarget(), { dataTransfer }); + await waitFor(() => expect(onAdd).toHaveBeenCalledWith([looseFile])); + + unmount(); + onAdd.mockClear(); + + renderComponent({ allowed: 'folder', allowsMultiple: true, onAdd }); + fireEvent.drop(getDropTarget(), { dataTransfer }); + + await waitFor(() => expect(onAdd).toHaveBeenCalledWith([folderFile])); + }); + + it('keeps every file from the first folder in single mode', async () => { + const onAdd = vi.fn(); + const first = makeFile('a.txt'); + const second = makeFile('b.txt'); + + renderComponent({ allowed: 'folder', onAdd }); + + fireEvent.drop(getDropTarget(), { + dataTransfer: createDataTransfer([ + dropDirectory('folder', [fileEntry(first), fileEntry(second)]), + ]), + }); + + await waitFor(() => expect(onAdd).toHaveBeenCalledWith([first, second])); + }); + + it('passes dropped files to onAdd regardless of accept', async () => { + const onAdd = vi.fn(); + const image = new File(['image'], 'image.png', { type: 'image/png' }); + const text = makeFile('notes.txt'); + + renderComponent({ + accept: ['image/*'], + allowsMultiple: true, + onAdd, + }); + + fireEvent.drop(getDropTarget(), { + dataTransfer: createDataTransfer([dropFile(image), dropFile(text)]), + }); + + await waitFor(() => expect(onAdd).toHaveBeenCalledWith([image, text])); + }); + + it('validates an item associated with a native file', async () => { + const file = makeFile('notes.txt') as FileUploadFile; + + renderComponent({ + accept: ['image/*'], + initialItems: [toItem(file)], + }); + + const item = screen.getByText('notes.txt').closest('[data-invalid]'); + const control = getDropTarget(); + + await waitFor(() => { + expect(item).toHaveAttribute('data-invalid'); + expect(control).toHaveAttribute('aria-invalid', 'true'); + }); + + expect( + screen.getByText('notes.txt — Unsupported file type') + ).toBeInTheDocument(); + }); + + it('validates accept by file extension independently of MIME type', async () => { + const accepted = makeSizedFile('image.PNG', 1, ''); + const rejected = makeSizedFile('image.jpg', 1, 'image/png'); + + renderComponent({ + accept: ['.png'], + allowsMultiple: true, + initialItems: [toItem(accepted), toItem(rejected)], + }); + + await waitFor(() => { + expect( + screen.getByText('image.PNG').closest('[data-multiple]') + ).not.toHaveAttribute('data-invalid'); + + expect( + screen.getByText('image.jpg').closest('[data-multiple]') + ).toHaveAttribute('data-invalid'); + }); + }); + + it('allows a file at maxFileSize and rejects a larger file', async () => { + const exact = makeSizedFile('exact.bin', 10); + + const { unmount } = renderComponent({ + initialItems: [toItem(exact)], + maxFileSize: exact.size, + }); + + expect(getDropTarget()).not.toHaveAttribute('aria-invalid'); + + expect( + screen.queryByText(/The file size limit has been exceeded/) + ).not.toBeInTheDocument(); + + unmount(); + + const oversized = makeSizedFile('oversized.bin', 11); + + renderComponent({ + initialItems: [toItem(oversized)], + maxFileSize: 10, + }); + + await waitFor(() => { + expect(getDropTarget()).toHaveAttribute('aria-invalid', 'true'); + }); + + expect( + screen.getByText('oversized.bin — The file size limit has been exceeded') + ).toBeInTheDocument(); + }); + + it('combines standard and custom validation errors in order', async () => { + const file = makeSizedFile('notes.txt', 11); + + renderComponent({ + accept: ['image/*'], + maxFileSize: 10, + validate: () => 'Custom validation failed', + initialItems: [toItem(file)], + }); + + expect( + await screen.findByText( + 'notes.txt — Unsupported file type notes.txt — The file size limit has been exceeded notes.txt — Custom validation failed' + ) + ).toBeInTheDocument(); + }); + + it('allows text and JSX to replace automatic validation errors', async () => { + const file = makeSizedFile('notes.txt', 11); + const initialItems = [toItem(file)]; + + const { unmount } = renderComponent({ + accept: ['image/*'], + initialItems, + errorMessage: 'Custom text error', + }); + + expect(await screen.findByText('Custom text error')).toBeInTheDocument(); + + expect( + screen.queryByText('notes.txt — Unsupported file type') + ).not.toBeInTheDocument(); + + unmount(); + + renderComponent({ + accept: ['image/*'], + initialItems, + errorMessage: Custom JSX error, + }); + + const customJSXError = await screen.findByText('Custom JSX error'); + + expect(customJSXError.tagName).toBe('STRONG'); + + expect( + screen.queryByText('notes.txt — Unsupported file type') + ).not.toBeInTheDocument(); + }); + + it('passes the validation result to an errorMessage function', async () => { + const file = makeSizedFile('notes.txt', 11); + const automaticError = 'notes.txt — Unsupported file type'; + + const renderError = vi.fn(({ validationErrors }: ValidationResult) => ( + Upload failed: {validationErrors.join(', ')} + )); + + renderComponent({ + accept: ['image/*'], + initialItems: [toItem(file)], + errorMessage: renderError, + }); + + expect( + await screen.findByText(`Upload failed: ${automaticError}`) + ).toBeInTheDocument(); + + expect(renderError).toHaveBeenLastCalledWith( + expect.objectContaining({ + isInvalid: true, + validationErrors: [automaticError], + }) + ); + }); + + it('allows hiding automatic errors without clearing the invalid state', async () => { + const file = makeSizedFile('notes.txt', 11); + + renderComponent({ + accept: ['image/*'], + initialItems: [toItem(file)], + errorMessage: null, + }); + + await waitFor(() => { + expect(getDropTarget()).toHaveAttribute('aria-invalid', 'true'); + }); + + expect( + screen.queryByText('notes.txt — Unsupported file type') + ).not.toBeInTheDocument(); + }); + + it('uses a custom file size validation message', async () => { + const file = makeSizedFile('large.bin', 11); + + renderComponent({ + initialItems: [toItem(file)], + maxFileSize: 10, + messages: { fileSizeLimitExceeded: 'File is too large' }, + }); + + expect( + await screen.findByText('large.bin — File is too large') + ).toBeInTheDocument(); + }); + + it('does not validate an item without a native file', () => { + const validate = vi.fn(() => 'Invalid file'); + + renderComponent({ + accept: ['image/*'], + maxFileSize: 0, + validate, + initialItems: [makeItem('server-file.txt', 100)], + }); + + expect(validate).not.toHaveBeenCalled(); + expect(getDropTarget()).not.toHaveAttribute('aria-invalid'); + expect(screen.queryByText(/Invalid file/)).not.toBeInTheDocument(); + }); + + it('shows custom validation errors and clears them after removal', async () => { + const user = userEvent.setup(); + const file = makeFile('large.txt') as FileUploadFile; + + renderComponent({ + initialItems: [toItem(file)], + validate: () => ['File is too large', 'File is blocked'], + }); + + const control = getDropTarget(); + + await waitFor(() => { + expect(control).toHaveAttribute('aria-invalid', 'true'); + }); + + expect( + screen.getByText( + 'large.txt — File is too large large.txt — File is blocked' + ) + ).toBeInTheDocument(); + + await user.click( + screen.getByRole('button', { name: /remove large\.txt/i }) + ); + + await waitFor(() => { + expect(getDropTarget()).not.toHaveAttribute('aria-invalid'); + }); + + expect(screen.queryByText(/File is too large/)).not.toBeInTheDocument(); + }); + + it('ignores disabled and non-file drags', () => { + const onAdd = vi.fn(); + const { rerender } = render(); + const root = screen.getByTestId(ROOT_TEST_ID); + let dropTarget = getDropTarget(); + const fileTransfer = createDataTransfer([dropFile('disabled.txt')]); + + fireEvent.dragEnter(dropTarget, { dataTransfer: fileTransfer }); + fireEvent.drop(dropTarget, { dataTransfer: fileTransfer }); + + expect(dropTarget).not.toHaveAttribute('data-drop-target'); + expect(root).not.toHaveAttribute('data-drop-target'); + expect(onAdd).not.toHaveBeenCalled(); + + rerender(); + dropTarget = getDropTarget(); + + const textTransfer = createDataTransfer([dropText()]); + + fireEvent.dragEnter(dropTarget, { dataTransfer: textTransfer }); + fireEvent.drop(dropTarget, { dataTransfer: textTransfer }); + + expect(dropTarget).not.toHaveAttribute('data-drop-target'); + expect(root).not.toHaveAttribute('data-drop-target'); + expect(onAdd).not.toHaveBeenCalled(); + }); + + it('clears the active target when the drag is cancelled', () => { + renderComponent(); + + const dropTarget = getDropTarget(); + const dataTransfer = createDataTransfer([dropFile('a.txt')]); + + fireEvent.dragEnter(dropTarget, { dataTransfer }); + expect(dropTarget).toHaveAttribute('data-drop-target'); + + fireEvent.dragEnd(window); + expect(dropTarget).not.toHaveAttribute('data-drop-target'); + }); + + it('cleans an external target on unmount', () => { + const targetRef = createRef(); + + const { unmount } = render( + <> +
+ + + ); + + const target = screen.getByTestId('external-target'); + const dataTransfer = createDataTransfer([dropFile('a.txt')]); + + fireEvent.dragEnter(target, { dataTransfer }); + expect(target).toHaveAttribute('data-drop-target'); + expect(getDropOverlay()).toBeInTheDocument(); + + unmount(); + expect(target).not.toHaveAttribute('data-drop-target'); + expect(getDropOverlay()).not.toBeInTheDocument(); + }); + + it('formats file size in item rows', () => { + renderComponent({ + initialItems: [makeItem('report.pdf', 148909)], + }); + + expect( + screen.getByText('148.91\u00a0KB', { normalizer: (value) => value }) + ).toBeInTheDocument(); + }); + + it('appends files in multiple mode', async () => { + const user = userEvent.setup(); + + renderComponent({ allowsMultiple: true }); + + await user.upload(getFileInput(), [makeFile('a.txt')]); + await user.upload(getFileInput(), [makeFile('b.txt')]); + + expect(screen.getByText('a.txt')).toBeInTheDocument(); + expect(screen.getByText('b.txt')).toBeInTheDocument(); + }); + + it('removes a file via the remove button and calls onRemove with the item id', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + + renderComponent({ + onRemove, + initialItems: [makeItem('remove-me.txt')], + }); + + await user.click(screen.getByRole('button', { name: /remove/i })); + + expect(screen.queryByText('remove-me.txt')).not.toBeInTheDocument(); + expect(onRemove).toHaveBeenCalledWith('remove-me.txt'); + }); + + it('renders a custom remove button icon', () => { + render( + + + custom-icon.txt + + + + + + ); + + expect(screen.getByTestId('custom-remove-icon')).toBeInTheDocument(); + }); + + it('restores focus to the browse link after removing the last file', async () => { + const user = userEvent.setup(); + + renderComponent({ initialItems: [makeItem('only.txt')] }); + + await user.click(screen.getByRole('button', { name: /remove/i })); + + await waitFor(() => { + expect(screen.getByText('select a file')).toHaveFocus(); + }); + }); + + it('moves focus to the next file after removing a middle one', async () => { + const user = userEvent.setup(); + + renderComponent({ + allowsMultiple: true, + initialItems: [ + makeItem('first.txt'), + makeItem('second.txt'), + makeItem('third.txt'), + ], + }); + + await user.click( + screen.getByRole('button', { name: /remove second\.txt/i }) + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /remove third\.txt/i }) + ).toHaveFocus(); + }); + }); + + it('moves focus to the previous file after removing the last one', async () => { + const user = userEvent.setup(); + + renderComponent({ + allowsMultiple: true, + initialItems: [ + makeItem('first.txt'), + makeItem('second.txt'), + makeItem('third.txt'), + ], + }); + + await user.click( + screen.getByRole('button', { name: /remove third\.txt/i }) + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /remove second\.txt/i }) + ).toHaveFocus(); + }); + }); + + it('removes a file with the Delete key when the remove button is focused', () => { + const onRemove = vi.fn(); + + renderComponent({ + onRemove, + initialItems: [makeItem('remove-me.txt')], + }); + + const button = screen.getByRole('button', { name: /remove/i }); + + button.focus(); + fireEvent.keyDown(button, { key: 'Delete' }); + + expect(screen.queryByText('remove-me.txt')).not.toBeInTheDocument(); + expect(onRemove).toHaveBeenCalledWith('remove-me.txt'); + }); + + it('removes a file with the Backspace key when the remove button is focused', () => { + const onRemove = vi.fn(); + + renderComponent({ + onRemove, + initialItems: [makeItem('remove-me.txt')], + }); + + const button = screen.getByRole('button', { name: /remove/i }); + + button.focus(); + fireEvent.keyDown(button, { key: 'Backspace' }); + + expect(screen.queryByText('remove-me.txt')).not.toBeInTheDocument(); + expect(onRemove).toHaveBeenCalledWith('remove-me.txt'); + }); + + it('shows a determinate spinner and marks the row loading', () => { + renderComponent({ + allowsMultiple: true, + initialItems: [ + { ...makeItem('loading.txt'), isLoading: true, progress: 42 }, + ], + }); + + const progress = screen.getByRole('progressbar'); + + expect(progress).toHaveAttribute('aria-valuenow', '42'); + expect(progress.closest('[data-loading]')).toHaveAttribute('data-loading'); + }); + + it('shows an indeterminate spinner when progress is not set', () => { + renderComponent({ + initialItems: [{ ...makeItem('loading.txt'), isLoading: true }], + }); + + expect(screen.getByRole('progressbar')).not.toHaveAttribute( + 'aria-valuenow' + ); + }); + + it('marks the row invalid when errorMessage is set', () => { + renderComponent({ + allowsMultiple: true, + initialItems: [ + { ...makeItem('bad.txt'), errorMessage: 'Something went wrong' }, + ], + }); + + expect( + screen.getByText('bad.txt').closest('[data-invalid]') + ).toHaveAttribute('data-invalid'); + }); + + it('marks the root invalid when the isInvalid prop is set', () => { + renderComponent({ isInvalid: true }); + + expect(screen.getByTestId(ROOT_TEST_ID)).toHaveAttribute('data-invalid'); + }); + + it('does not infer root invalid state from item data', () => { + renderComponent({ + initialItems: [ + { ...makeItem('bad.txt'), errorMessage: 'Something went wrong' }, + ], + }); + + expect(screen.getByTestId(ROOT_TEST_ID)).not.toHaveAttribute( + 'data-invalid' + ); + }); + + it('does not call onAdd when disabled', async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + + renderComponent({ isDisabled: true, onAdd }); + + await user.upload(getFileInput(), makeFile('nope.txt')); + + expect(screen.queryByText('nope.txt')).not.toBeInTheDocument(); + expect(onAdd).not.toHaveBeenCalled(); + }); + + it('opens the file dialog when the browse link is pressed', async () => { + const user = userEvent.setup(); + + renderComponent(); + + const clickSpy = vi + .spyOn(getFileInput(), 'click') + .mockImplementation(() => undefined); + + await user.click(screen.getByText('select a file')); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('supports a custom browse link label', async () => { + const user = userEvent.setup(); + + render( + ( + + + Choose files + + + )} + > + {renderItem} + + ); + + const clickSpy = vi + .spyOn(getFileInput(), 'click') + .mockImplementation(() => undefined); + + const triggerContent = screen.getByText('Choose files'); + + expect(triggerContent.closest('a')).toBeInTheDocument(); + + await user.click(triggerContent); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('passes the disabled state to the browse link', async () => { + const user = userEvent.setup(); + + renderComponent({ isDisabled: true }); + + const clickSpy = vi + .spyOn(getFileInput(), 'click') + .mockImplementation(() => undefined); + + const trigger = screen.getByText('select a file'); + + expect(trigger).toHaveAttribute('data-disabled'); + + await user.click(trigger); + + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it('restores focus to the browse link in multiple mode', async () => { + const user = userEvent.setup(); + + function MultipleFileUpload() { + const [items, setItems] = useState([ + makeItem('only.txt'), + ]); + + return ( + + setItems((current) => current.filter((item) => item.id !== id)) + } + > + {renderItem} + + ); + } + + render(); + + await user.click(screen.getByRole('button', { name: /remove only\.txt/i })); + + await waitFor(() => { + expect(screen.getByText('select files')).toHaveFocus(); + }); + }); + + it('supports custom collection item rendering', async () => { + const user = userEvent.setup(); + + function CustomRender() { + const [items, setItems] = useState([ + makeItem('a.txt'), + ]); + + return ( + setItems((prev) => [...prev, ...files.map(toItem)])} + onRemove={(id) => + setItems((prev) => prev.filter((item) => item.id !== id)) + } + > + {(item) => ( + + {item.name} + + + )} + + ); + } + + render(); + + expect(screen.getByText('a.txt')).toBeInTheDocument(); + expect(screen.getByText('Drop more or')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /remove a\.txt/i })); + + expect(screen.queryByText('a.txt')).not.toBeInTheDocument(); + }); + + it('supports static FileUpload.Item children without items', () => { + render( + + + static.txt + + + + ); + + expect(screen.getByText('static.txt')).toBeInTheDocument(); + }); + + it('shows the full item name in a tooltip when it overflows', async () => { + const user = userEvent.setup({ delay: 0 }); + const fileName = 'a-very-long-security-scan-report.json'; + + class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + font: '', + measureText: (value: string) => ({ width: value.length * 8 }), + } as CanvasRenderingContext2D); + + const renderUpload = () => ( + + + + {fileName} + + + + ); + + const { rerender } = render(renderUpload()); + const name = screen.getByTestId('item-name'); + + Object.defineProperties(name, { + clientWidth: { configurable: true, value: 100 }, + clientHeight: { configurable: true, value: 20 }, + }); + + rerender(renderUpload()); + + expect(name).toHaveAttribute('data-overflowing'); + expect(name.firstChild?.textContent).toBe('a-ver….json'); + + fireEvent.pointerMove(name, { pointerType: 'mouse' }); + fireEvent.mouseMove(name); + await user.hover(name); + + expect(await screen.findByRole('tooltip')).toHaveTextContent(fileName); + }); + + it('forwards a ref on FileUpload.Item to its root element', () => { + const ref = createRef(); + + render( + + + ref-item.txt + + + ); + + expect(ref.current).toBe(screen.getByTestId('item')); + expect(ref.current?.tagName).toBe('DIV'); + expect(ref.current).not.toHaveAttribute('role'); + }); + + it('uses the compact list empty state without calling renderEmptyState', () => { + const renderEmptyState = vi.fn(() =>
large empty
); + const listEmptyRef = createRef(); + + render( + + {renderItem} + + ); + + expect(renderEmptyState).not.toHaveBeenCalled(); + + expect(document.getElementById('list-empty')).toHaveAttribute( + 'data-size', + 'compact' + ); + + expect(document.getElementById('list-empty')).toHaveClass( + 'custom-list-empty' + ); + + expect(listEmptyRef.current).toBe(document.getElementById('list-empty')); + expect(screen.queryByText('large empty')).not.toBeInTheDocument(); + }); + + it('uses the list empty state for an external drop target', () => { + const targetRef = createRef(); + const renderEmptyState = vi.fn(() =>
large empty
); + + render( + <> +
+ + {renderItem} + + + ); + + expect(renderEmptyState).not.toHaveBeenCalled(); + expect(screen.getByText('select files')).toBeInTheDocument(); + expect(screen.queryByText('large empty')).not.toBeInTheDocument(); + }); + + it('applies add-more slot props once populated', () => { + const ref = createRef(); + + render( + + {renderItem} + + ); + + expect(document.getElementById('add-more')).toHaveAttribute( + 'data-size', + 'compact' + ); + + expect(document.getElementById('add-more')).toHaveClass('custom-add-more'); + expect(ref.current).toBe(document.getElementById('add-more')); + expect(screen.getByText('Drop more or')).toBeInTheDocument(); + expect(screen.queryByText('replacement content')).not.toBeInTheDocument(); + }); + + it('applies list slot props to the collection element', () => { + render( + + {renderItem} + + ); + + expect(document.getElementById('list')).toHaveClass('custom-list'); + + expect(document.getElementById('list')).toHaveStyle({ + maxBlockSize: '240px', + }); + }); + + it('renders the single selected file and hides the empty trigger', () => { + renderComponent({ initialItems: [makeItem('single.txt')] }); + + expect(screen.getByText('single.txt')).toBeInTheDocument(); + expect(screen.queryByText('select a file')).not.toBeInTheDocument(); + }); + + it('renders the EmptyState title in the multiple empty default state', () => { + renderComponent({ allowsMultiple: true }); + + expect(screen.getByText('Drag here')).toBeInTheDocument(); + }); + + it('renders custom empty-state children without filling missing parts', () => { + render( + ( + + + icon + + + )} + > + {renderItem} + + ); + + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + expect(screen.queryByText('Drag here')).not.toBeInTheDocument(); + expect(screen.queryByText('select files')).not.toBeInTheDocument(); + }); + + it('supports a fully custom empty state', () => { + render( +
Nothing uploaded
} + > + {renderItem} +
+ ); + + expect(screen.getByText('Nothing uploaded')).toBeInTheDocument(); + expect(screen.queryByText('select a file')).not.toBeInTheDocument(); + }); + + it('hides the empty state when renderEmptyState returns null', () => { + render( + null} + > + {renderItem} + + ); + + expect(screen.queryByText('select a file')).not.toBeInTheDocument(); + }); + + it('overrides localized messages without replacing the remaining strings', () => { + render( + + {renderItem} + + ); + + expect(screen.getByText('Add another file otherwise')).toBeInTheDocument(); + expect(screen.getByText('select files')).toBeInTheDocument(); + }); + + it('uses message overrides in the list empty state', () => { + renderComponent({ + messages: { + listEmptyText: 'Drop a document here', + browseFile: 'choose a document', + }, + }); + + const trigger = screen.getByText('choose a document'); + + expect(trigger).toBeInTheDocument(); + + expect(trigger.parentElement).toHaveTextContent( + 'Drop a document here or choose a document' + ); + }); + + it('formats numeric ItemSize children using the current locale', () => { + render( + + + + {148909} + + + + ); + + expect( + screen.getByText('148,91\u00a0КБ', { normalizer: (value) => value }) + ).toBeInTheDocument(); + }); + + it('renders nothing for a non-finite numeric ItemSize without throwing', () => { + expect(() => + render( + + + + {Number(undefined)} + + + + ) + ).not.toThrow(); + + expect(screen.queryByTestId('size')).not.toBeInTheDocument(); + }); + + it('formats sizes with the IEC unit system via fileSizeFormat', () => { + render( + + + + {1024} + + + + ); + + expect( + screen.getByText('1 KiB', { normalizer: (value) => value }) + ).toBeInTheDocument(); + }); + + it('localizes strings via the Provider locale', () => { + render( + + + {renderItem} + + + ); + + expect(screen.getByText('выберите файл')).toBeInTheDocument(); + }); +}); + +const fileEntry = (file: File): FileSystemFileEntry => + ({ + name: file.name, + isFile: true, + isDirectory: false, + file: (resolve: FileCallback) => resolve(file), + }) as unknown as FileSystemFileEntry; + +const directoryEntry = ( + name: string, + entries: FileSystemEntry[], + batchSize = entries.length || 1 +): FileSystemDirectoryEntry => + ({ + name, + isFile: false, + isDirectory: true, + createReader: () => { + let offset = 0; + + return { + readEntries: (resolve: FileSystemEntriesCallback) => { + const batch = entries.slice(offset, offset + batchSize); + + offset += batch.length; + resolve(batch); + }, + }; + }, + }) as unknown as FileSystemDirectoryEntry; + +const dropFile = (value: string | File): DataTransferItem => { + const file = typeof value === 'string' ? makeFile(value) : value; + + return { + kind: 'file', + type: file.type, + getAsFile: () => file, + getAsString: () => undefined, + webkitGetAsEntry: () => fileEntry(file), + } as DataTransferItem; +}; + +const dropText = (): DataTransferItem => + ({ + kind: 'string', + type: 'text/plain', + getAsFile: () => null, + getAsString: () => undefined, + webkitGetAsEntry: () => null, + }) as DataTransferItem; + +const dropDirectory = ( + name: string, + entries: FileSystemEntry[], + batchSize?: number +): DataTransferItem => + ({ + kind: 'file', + type: '', + getAsFile: () => null, + getAsString: () => undefined, + webkitGetAsEntry: () => directoryEntry(name, entries, batchSize), + }) as DataTransferItem; + +const createDataTransfer = ( + items: DataTransferItem[], + fallbackFiles = items.flatMap((item) => { + const file = item.getAsFile(); + + return file ? [file] : []; + }) +): DataTransfer => + ({ + items, + files: fallbackFiles, + types: items.some((item) => item.kind === 'file') + ? ['Files'] + : ['text/plain'], + dropEffect: 'none', + }) as unknown as DataTransfer; + +describe('readDroppedFiles', () => { + it('extracts native files and ignores non-file drop items', async () => { + const files = await readDroppedFiles( + createDataTransfer([dropFile('a.txt'), dropText(), dropFile('b.txt')]) + ); + + expect(files.map((file) => file.name)).toEqual(['a.txt', 'b.txt']); + }); + + it('returns an empty array when there are no files', async () => { + const files = await readDroppedFiles(createDataTransfer([dropText()])); + + expect(files).toEqual([]); + }); + + it('expands a dropped folder into its files', async () => { + const files = await readDroppedFiles( + createDataTransfer([ + dropDirectory('folder', [ + fileEntry(makeFile('a.txt')), + fileEntry(makeFile('b.txt')), + ]), + ]) + ); + + expect(files.map((file) => file.name)).toEqual(['a.txt', 'b.txt']); + + expect(files.map((file) => file.relativePath)).toEqual([ + 'folder/a.txt', + 'folder/b.txt', + ]); + }); + + it('expands nested folders recursively and keeps order', async () => { + const files = await readDroppedFiles( + createDataTransfer([ + dropFile('root.txt'), + dropDirectory( + 'outer', + [ + fileEntry(makeFile('a.txt')), + directoryEntry('inner', [fileEntry(makeFile('b.txt'))]), + ], + 1 + ), + ]) + ); + + expect(files.map((file) => file.name)).toEqual([ + 'root.txt', + 'a.txt', + 'b.txt', + ]); + + expect(files.map((file) => file.relativePath)).toEqual([ + '', + 'outer/a.txt', + 'outer/inner/b.txt', + ]); + }); + + it('ignores non-file entries inside a dropped folder', async () => { + const files = await readDroppedFiles( + createDataTransfer([ + dropDirectory('folder', [ + fileEntry(makeFile('a.txt')), + { isFile: false, isDirectory: false } as FileSystemEntry, + ]), + ]) + ); + + expect(files.map((file) => file.name)).toEqual(['a.txt']); + }); + + it('falls back to DataTransfer.files when items are unavailable', async () => { + const fallbackFiles = [makeFile('a.txt'), makeFile('b.txt')]; + const files = await readDroppedFiles(createDataTransfer([], fallbackFiles)); + + expect(files).toEqual(fallbackFiles); + }); +}); diff --git a/packages/components/src/components/FileUpload/FileUpload.tsx b/packages/components/src/components/FileUpload/FileUpload.tsx new file mode 100644 index 000000000..668d4f458 --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUpload.tsx @@ -0,0 +1,513 @@ +'use client'; + +import type { Ref } from 'react'; +import { + useRef, + useState, + useMemo, + forwardRef, + useCallback, + useContext, + useLayoutEffect, +} from 'react'; + +import type { + Key, + Node, + Collection as AriaCollection, +} from '@koobiq/react-core'; +import { + clsx, + mergeProps, + useDOMRef, + useKeyedRefs, + useFileSizeFormatter, + useLocalizedStringFormatter, +} from '@koobiq/react-core'; +import { + Collection, + Provider, + TextContext, + DEFAULT_SLOT, + CollectionBuilder, + FieldErrorContext, + CollectionRendererContext, +} from '@koobiq/react-primitives'; + +import type { + FormFieldProps, + FormFieldErrorProps, + FormFieldLabelProps, + FormFieldCaptionProps, +} from '../FormField'; +import { FormField } from '../FormField'; + +import { + FileUploadEmpty, + FileUploadEmptyIcon, + FileUploadEmptyTitle, + FileUploadItem, + FileUploadItemContent, + FileUploadItemIcon, + FileUploadItemName, + FileUploadItemSize, + FileUploadList, + FileUploadRemoveButton, + FileUploadTrigger, + FileUploadEmptyDescription, +} from './components'; +import { FileUploadDropTargetOverlay } from './components/DropTargetOverlay'; +import s from './FileUpload.module.css'; +import type { FileUploadContextValue } from './FileUploadContext'; +import { FileUploadContext } from './FileUploadContext'; +import { useFileDropTarget, useFileUploadField } from './hooks'; +import intlMessages from './intl'; +import type { + FileUploadComponent, + FileUploadMessages, + FileUploadProps, +} from './types'; +import { getCollectionKeys, prepareFileUploadFiles } from './utils'; + +type PendingFocus = { type: 'item'; id: Key } | { type: 'trigger' } | null; + +type ItemValidation = { + name: string; + errors: string[]; +}; + +type FileUploadInnerProps = { + props: Omit, 'ref' | 'items' | 'children'>; + collection: AriaCollection>; + rootRef?: Ref; +}; + +function FileUploadInner({ + props, + rootRef, + collection, +}: FileUploadInnerProps) { + const { + isInvalid: isInvalidProp = false, + allowsMultiple = false, + isDisabled = false, + allowed = 'file', + size = 'default', + accept, + maxFileSize, + validate, + onAdd, + style, + onRemove, + className, + slotProps, + dropzoneTarget, + label, + caption, + errorMessage, + isLabelHidden, + isRequired = false, + labelPlacement, + labelAlign, + id, + role, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + 'aria-details': ariaDetails, + 'aria-errormessage': ariaErrorMessage, + messages: messageOverrides, + fileSizeFormat, + renderEmptyState, + 'data-testid': testId, + ...other + } = props; + + const domRef = useDOMRef(rootRef); + const controlRef = useRef(null); + const { CollectionRoot } = useContext(CollectionRendererContext); + + const isEmpty = collection.size === 0; + + const collectionKeys = useMemo( + () => getCollectionKeys(collection), + [collection] + ); + + const stringFormatter = useLocalizedStringFormatter(intlMessages); + + const messages = useMemo(() => { + const localizedMessages = { + emptyTitle: stringFormatter.format('emptyTitle'), + listEmptyText: stringFormatter.format('listEmptyText'), + dropOverlayTitle: stringFormatter.format('dropOverlayTitle'), + addMoreText: stringFormatter.format('addMoreText'), + alternativeSeparator: stringFormatter.format('alternativeSeparator'), + browseFile: stringFormatter.format('browseFile'), + browseFiles: stringFormatter.format('browseFiles'), + browseFolder: stringFormatter.format('browseFolder'), + browseFilesOrFolder: stringFormatter.format('browseFilesOrFolder'), + browseFolderMixed: stringFormatter.format('browseFolderMixed'), + removeButtonLabel: stringFormatter.format('removeButtonLabel'), + unsupportedFileType: stringFormatter.format('unsupportedFileType'), + fileSizeLimitExceeded: stringFormatter.format('fileSizeLimitExceeded'), + }; + + return { ...localizedMessages, ...messageOverrides }; + }, [stringFormatter, messageOverrides]); + + const fileSizeFormatter = useFileSizeFormatter(fileSizeFormat); + + const getItemRef = useKeyedRefs(); + const triggerRef = useRef(null); + const pendingFocus = useRef(null); + + const [itemValidations, setItemValidations] = useState< + Map + >(new Map()); + + const unregisterItemValidation = useCallback((id: Key) => { + setItemValidations((current) => { + if (!current.has(id)) return current; + + const next = new Map(current); + next.delete(id); + + return next; + }); + }, []); + + const registerItemValidation = useCallback( + (id: Key, name: string, errors: string[]) => { + if (errors.length === 0) { + unregisterItemValidation(id); + + return; + } + + setItemValidations((current) => { + const previous = current.get(id); + + const isUnchanged = + previous?.name === name && + previous.errors.length === errors.length && + previous.errors.every((error, index) => error === errors[index]); + + if (isUnchanged) return current; + + return new Map(current).set(id, { name, errors }); + }); + }, + [unregisterItemValidation] + ); + + const validationErrors = useMemo( + () => + collectionKeys.flatMap((id) => { + const validation = itemValidations.get(id); + + if (!validation) return []; + + return validation.errors.map((error) => + validation.name ? `${validation.name} — ${error}` : error + ); + }), + [collectionKeys, itemValidations] + ); + + const isInvalid = isInvalidProp || validationErrors.length > 0; + + const field = useFileUploadField({ + id, + role, + label, + caption, + hasErrorMessage: errorMessage != null || validationErrors.length > 0, + isDisabled, + isInvalid, + validationErrors, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + 'aria-details': ariaDetails, + 'aria-errormessage': ariaErrorMessage, + }); + + const labelProps = mergeProps<(FormFieldLabelProps<'span'> | undefined)[]>( + { + as: 'span', + children: label, + isHidden: isLabelHidden, + isRequired, + }, + slotProps?.label, + field.labelProps + ); + + const captionProps = mergeProps<(FormFieldCaptionProps | undefined)[]>( + { children: caption }, + slotProps?.caption + ); + + let errorMessageChildren = errorMessage; + + if (errorMessage === null) { + // RAC falls back to validationErrors for null children. An empty element + // keeps the error relationship while intentionally rendering no text. + errorMessageChildren = <>; + } + + const errorMessageProps = mergeProps<(FormFieldErrorProps | undefined)[]>( + { children: errorMessageChildren }, + slotProps?.errorMessage + ); + + const setTriggerRef = useCallback((element: HTMLElement | null) => { + triggerRef.current = element; + }, []); + + const formatSize = useCallback( + (bytes: number) => fileSizeFormatter.format(bytes), + [fileSizeFormatter] + ); + + const addFiles = useCallback( + (files: File[]) => { + if (isDisabled || files.length === 0) return; + + const preparedFiles = prepareFileUploadFiles(files, { + allowed, + allowsMultiple, + }); + + if (preparedFiles.length > 0) onAdd?.(preparedFiles); + }, + [allowed, isDisabled, allowsMultiple, onAdd] + ); + + const isFullscreen = dropzoneTarget === 'fullscreen'; + + const dropRef = + dropzoneTarget && dropzoneTarget !== 'fullscreen' + ? dropzoneTarget + : controlRef; + + const isDropTarget = useFileDropTarget({ + ref: dropRef, + isFullscreen, + isDisabled, + onDrop: addFiles, + }); + + const removeItem = useCallback( + (id: Key) => { + if (isDisabled) return; + + const index = collectionKeys.indexOf(id); + + if (index < 0) return; + + const neighbor = collectionKeys[index + 1] ?? collectionKeys[index - 1]; + + pendingFocus.current = neighbor + ? { type: 'item', id: neighbor } + : { type: 'trigger' }; + + onRemove?.(id); + }, + [isDisabled, collectionKeys, onRemove] + ); + + useLayoutEffect(() => { + const pending = pendingFocus.current; + + if (!pending) return; + + pendingFocus.current = null; + + if (pending.type === 'item') { + getItemRef(pending.id).current?.focus(); + } else { + triggerRef.current?.focus(); + } + }, [collectionKeys, getItemRef]); + + const contextValue = useMemo( + () => ({ + accept, + maxFileSize, + validate, + allowed, + size, + isDisabled, + isDropTarget, + allowsMultiple, + addFiles, + removeItem, + getItemRef, + setTriggerRef, + registerItemValidation, + unregisterItemValidation, + messages, + formatSize, + }), + [ + accept, + maxFileSize, + validate, + allowed, + size, + isDisabled, + isDropTarget, + allowsMultiple, + addFiles, + removeItem, + getItemRef, + setTriggerRef, + registerItemValidation, + unregisterItemValidation, + messages, + formatSize, + ] + ); + + const showsLargeEmpty = + isEmpty && + allowsMultiple && + size === 'default' && + dropzoneTarget === undefined; + + const rootProps = mergeProps<(FormFieldProps | undefined)[]>(other, { + ref: domRef, + fullWidth: true, + style, + labelPlacement, + labelAlign, + 'data-size': size, + 'data-empty': isEmpty || undefined, + 'data-list-empty': (isEmpty && !showsLargeEmpty) || undefined, + 'data-disabled': isDisabled || undefined, + 'data-invalid': isInvalid || undefined, + 'data-multiple': allowsMultiple || undefined, + 'data-required': isRequired || undefined, + 'data-testid': testId, + className: clsx(s.base, className), + }); + + const overlayTarget = isFullscreen + ? (domRef.current?.ownerDocument.documentElement ?? null) + : (dropzoneTarget?.current ?? null); + + return ( + + + +
+ + + + + + + +
+
+ {overlayTarget && isDropTarget && ( + + + + + {messages.dropOverlayTitle} + + + + )} +
+ ); +} + +function FileUploadRender( + props: Omit, 'ref'>, + ref?: Ref +) { + const { items, children, allowsMultiple = false, ...other } = props; + + return ( + {children}} + > + {(collection) => ( + + )} + + ); +} + +const FileUploadComponent = forwardRef(FileUploadRender) as FileUploadComponent; + +type CompoundedComponent = typeof FileUploadComponent & { + Empty: typeof FileUploadEmpty; + EmptyIcon: typeof FileUploadEmptyIcon; + EmptyTitle: typeof FileUploadEmptyTitle; + EmptyDescription: typeof FileUploadEmptyDescription; + Trigger: typeof FileUploadTrigger; + Item: typeof FileUploadItem; + ItemIcon: typeof FileUploadItemIcon; + ItemContent: typeof FileUploadItemContent; + ItemName: typeof FileUploadItemName; + ItemSize: typeof FileUploadItemSize; + RemoveButton: typeof FileUploadRemoveButton; +}; + +/** + * `FileUpload` lets users pick, display and remove files. It handles + * selection (system dialog and drag-and-drop), collection rendering and remove + * intent, but the consumer owns the item data. + */ +export const FileUpload = FileUploadComponent as CompoundedComponent; + +FileUpload.Empty = FileUploadEmpty; +FileUpload.EmptyIcon = FileUploadEmptyIcon; +FileUpload.EmptyTitle = FileUploadEmptyTitle; +FileUpload.EmptyDescription = FileUploadEmptyDescription; +FileUpload.Trigger = FileUploadTrigger; +FileUpload.Item = FileUploadItem; +FileUpload.ItemIcon = FileUploadItemIcon; +FileUpload.ItemContent = FileUploadItemContent; +FileUpload.ItemName = FileUploadItemName; +FileUpload.ItemSize = FileUploadItemSize; +FileUpload.RemoveButton = FileUploadRemoveButton; diff --git a/packages/components/src/components/FileUpload/FileUploadContext.ts b/packages/components/src/components/FileUpload/FileUploadContext.ts new file mode 100644 index 000000000..f1bd20b05 --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUploadContext.ts @@ -0,0 +1,79 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { RefObject } from 'react'; + +import type { Key } from '@koobiq/react-core'; + +import type { + FileUploadValidate, + FileUploadMessages, + FileUploadPropSize, + FileUploadPropAllowed, +} from './types'; + +export type FileUploadContextValue = { + allowsMultiple: boolean; + accept?: string[]; + maxFileSize?: number; + validate?: FileUploadValidate; + allowed: FileUploadPropAllowed; + size: FileUploadPropSize; + isDisabled: boolean; + isDropTarget: boolean; + /** Add the given native files. The consumer decides how they affect `items`. */ + addFiles: (files: File[]) => void; + /** Request removing the item with the given id and restore focus after rerender. */ + removeItem: (id: Key) => void; + /** The remove-button ref of an item, used to restore focus after removal. */ + getItemRef: (id: Key) => RefObject; + /** Register the browse-link element for focus restoration. */ + setTriggerRef: (element: HTMLElement | null) => void; + /** Register validation errors produced by a rendered item. */ + registerItemValidation: (id: Key, name: string, errors: string[]) => void; + /** Remove validation errors when an item is no longer rendered. */ + unregisterItemValidation: (id: Key) => void; + /** Resolved localized strings. */ + messages: FileUploadMessages; + /** Locale-aware file-size formatter. */ + formatSize: (bytes: number) => string; +}; + +export type FileUploadItemContextValue = { + id: Key; + nameText?: string; + isDisabled: boolean; + isInvalid: boolean; + rootRef: RefObject; +}; + +export const FileUploadContext = createContext( + null +); + +export const FileUploadItemContext = + createContext(null); + +export const useFileUploadContext = (): FileUploadContextValue => { + const context = useContext(FileUploadContext); + + if (context === null) { + throw new Error( + 'FileUpload: compound components must be rendered inside a .' + ); + } + + return context; +}; + +export const useFileUploadItemContext = (): FileUploadItemContextValue => { + const context = useContext(FileUploadItemContext); + + if (context === null) { + throw new Error( + 'FileUpload: item parts must be rendered inside a .' + ); + } + + return context; +}; diff --git a/packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.module.css b/packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.module.css new file mode 100644 index 000000000..0f745db98 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.module.css @@ -0,0 +1,28 @@ +.overlay { + z-index: var(--kbq-layer-modal); + display: flex; + position: fixed; + overflow: hidden; + align-items: center; + box-sizing: border-box; + justify-content: center; + pointer-events: none; + color: var(--file-upload-text-color); + border-radius: var(--kbq-size-s); + background-color: var(--kbq-background-overlay-theme); + border: var(--kbq-size-border-width) dashed var(--kbq-line-theme-fade); +} + +.overlay[data-fullscreen] { + border-radius: 0; + border: none; + + &::after { + content: ''; + inset: var(--kbq-size-s); + position: absolute; + pointer-events: none; + border: var(--kbq-size-border-width) dashed var(--kbq-line-theme-fade); + border-radius: var(--kbq-size-s); + } +} diff --git a/packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.tsx b/packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.tsx new file mode 100644 index 000000000..848cd825d --- /dev/null +++ b/packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { forwardRef, useCallback, useLayoutEffect, useState } from 'react'; +import type { CSSProperties, ReactNode, ComponentPropsWithoutRef } from 'react'; + +import { clsx } from '@koobiq/react-core'; +import { Overlay } from '@koobiq/react-primitives'; + +import s from './DropTargetOverlay.module.css'; + +type FileUploadDropTargetOverlayProps = { + children: ReactNode; + target: HTMLElement; + isFullscreen?: boolean; +} & ComponentPropsWithoutRef<'div'>; + +const getTargetStyle = (target: HTMLElement): CSSProperties => { + const targetWindow = target.ownerDocument.defaultView; + + if ( + targetWindow && + (target === target.ownerDocument.documentElement || + target === target.ownerDocument.body) + ) { + return { + top: 0, + left: 0, + width: targetWindow.innerWidth, + height: targetWindow.innerHeight, + }; + } + + const { top, left, width, height } = target.getBoundingClientRect(); + + return { top, left, width, height }; +}; + +export const FileUploadDropTargetOverlay = forwardRef< + HTMLDivElement, + FileUploadDropTargetOverlayProps +>((props, ref) => { + const { + children, + target, + isFullscreen, + className, + style: styleProp, + ...other + } = props; + + const [targetStyle, setTargetStyle] = useState(() => + getTargetStyle(target) + ); + + const updatePosition = useCallback(() => { + setTargetStyle(getTargetStyle(target)); + }, [target]); + + useLayoutEffect(() => { + updatePosition(); + + const targetWindow = target.ownerDocument.defaultView; + + const resizeObserver = + typeof ResizeObserver === 'undefined' + ? undefined + : new ResizeObserver(updatePosition); + + resizeObserver?.observe(target); + targetWindow?.addEventListener('resize', updatePosition); + targetWindow?.addEventListener('scroll', updatePosition, true); + + return () => { + resizeObserver?.disconnect(); + targetWindow?.removeEventListener('resize', updatePosition); + targetWindow?.removeEventListener('scroll', updatePosition, true); + }; + }, [target, updatePosition]); + + return ( + +
+ {children} +
+
+ ); +}); + +FileUploadDropTargetOverlay.displayName = 'FileUploadDropTargetOverlay'; diff --git a/packages/components/src/components/FileUpload/components/DropTargetOverlay/index.ts b/packages/components/src/components/FileUpload/components/DropTargetOverlay/index.ts new file mode 100644 index 000000000..6dd7b0bb5 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/DropTargetOverlay/index.ts @@ -0,0 +1 @@ +export * from './DropTargetOverlay'; diff --git a/packages/components/src/components/FileUpload/components/Empty/Empty.module.css b/packages/components/src/components/FileUpload/components/Empty/Empty.module.css new file mode 100644 index 000000000..e0dac179c --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Empty/Empty.module.css @@ -0,0 +1,22 @@ +.empty { + color: var(--file-upload-caption-color); +} + +.large { + padding-block: 0; + padding-inline: var(--kbq-size-xxl); + + .icon { + margin-block-end: var(--kbq-size-m); + color: var(--file-upload-upload-icon-color); + } + + .title { + margin-block-end: var(--kbq-size-xs); + color: var(--file-upload-text-color); + } + + .description { + color: var(--file-upload-text-color); + } +} diff --git a/packages/components/src/components/FileUpload/components/Empty/Empty.tsx b/packages/components/src/components/FileUpload/components/Empty/Empty.tsx new file mode 100644 index 000000000..9026f968e --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Empty/Empty.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { forwardRef } from 'react'; + +import { clsx, useDOMRef } from '@koobiq/react-core'; +import { IconCloudArrowUpO24 } from '@koobiq/react-icons'; + +import { EmptyState } from '../../../EmptyState'; +import { useFileUploadContext } from '../../FileUploadContext'; +import type { + FileUploadEmptyProps, + FileUploadEmptyIconProps, + FileUploadEmptyTitleProps, + FileUploadEmptyDescriptionProps, +} from '../../types'; +import { FileUploadTriggers } from '../Trigger'; + +import s from './Empty.module.css'; + +export const FileUploadEmpty = forwardRef( + (props, ref) => { + const { + children, + className, + style, + 'data-testid': testId, + ...other + } = props; + + const { size, isDisabled, allowsMultiple } = useFileUploadContext(); + const domRef = useDOMRef(ref); + + return ( +
+ + {children ?? ( + <> + + + + + )} + +
+ ); + } +); + +export const FileUploadEmptyIcon = forwardRef< + HTMLSpanElement, + FileUploadEmptyIconProps +>((props, ref) => { + const { children, className, style, 'data-testid': testId, ...other } = props; + + return ( + + {children ?? } + + ); +}); + +export const FileUploadEmptyTitle = forwardRef< + HTMLSpanElement, + FileUploadEmptyTitleProps +>((props, ref) => { + const { children, className, style, 'data-testid': testId, ...other } = props; + const { messages } = useFileUploadContext(); + + return ( + + {children ?? messages.emptyTitle} + + ); +}); + +export const FileUploadEmptyDescription = forwardRef< + HTMLSpanElement, + FileUploadEmptyDescriptionProps +>((props, ref) => { + const { children, className, style, 'data-testid': testId, ...other } = props; + const { messages } = useFileUploadContext(); + + return ( + + {children ?? ( + <> + {messages.alternativeSeparator} + + )} + + ); +}); + +FileUploadEmpty.displayName = 'FileUpload.Empty'; +FileUploadEmptyIcon.displayName = 'FileUpload.EmptyIcon'; +FileUploadEmptyTitle.displayName = 'FileUpload.EmptyTitle'; +FileUploadEmptyDescription.displayName = 'FileUpload.EmptyDescription'; diff --git a/packages/components/src/components/FileUpload/components/Empty/index.ts b/packages/components/src/components/FileUpload/components/Empty/index.ts new file mode 100644 index 000000000..47dea7850 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Empty/index.ts @@ -0,0 +1 @@ +export * from './Empty'; diff --git a/packages/components/src/components/FileUpload/components/Item/Item.module.css b/packages/components/src/components/FileUpload/components/Item/Item.module.css new file mode 100644 index 000000000..174e1b8c8 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Item/Item.module.css @@ -0,0 +1,119 @@ +@import url('../../../../styles/mixins.css'); + +.base { + display: flex; + outline: none; + align-items: center; + box-sizing: border-box; + min-inline-size: 0; + position: relative; + min-block-size: 40px; + border-radius: var(--kbq-size-xs); + padding-inline: var(--kbq-size-m) 0; + color: var(--file-upload-text-color); + gap: var(--file-upload-grid-cell-gap); + border-inline: var(--kbq-size-xxs) solid var(--kbq-background-transparent); + transition: + background-color var(--kbq-transition-default), + box-shadow var(--kbq-transition-default); + + &::after { + content: ''; + position: absolute; + border-radius: inherit; + pointer-events: none; + inset: 0; + } +} + +.base:not([data-multiple]) { + border: none; + min-block-size: var(--file-upload-single-min-block-size, 44px); + padding-inline-start: var(--file-upload-single-padding-inline); +} + +.base[data-multiple] { + display: grid; + cursor: pointer; + grid-column: 1 / -1; + grid-template-columns: subgrid; +} + +.base[data-multiple][data-hovered]::after { + background-color: var(--kbq-states-background-transparent-hover); +} + +.base[data-invalid] { + color: var(--kbq-foreground-error); + + .icon { + color: var(--kbq-icon-error); + } + + .size { + color: var(--kbq-foreground-error-secondary); + } + + .remove[data-focus-visible] { + --icon-button-outline-color: var(--kbq-states-line-focus-error); + } +} + +.base[data-invalid][data-multiple][data-hovered]::after { + background-color: var(--kbq-states-background-error-less-hover); +} + +.base[data-disabled] { + cursor: default; + color: var(--kbq-states-foreground-disabled); + + .icon { + color: var(--kbq-states-icon-disabled); + } +} + +.icon { + flex: none; + display: flex; + align-items: center; + color: var(--file-upload-left-icon-color); +} + +.content { + display: flex; + flex: 1 1 auto; + min-inline-size: 0; + align-items: center; + gap: var(--kbq-size-s); +} + +.base[data-multiple] .content { + display: grid; + grid-column: 2 / 4; + grid-template-columns: subgrid; +} + +.name { + @mixin ellipsis; + + flex: 1 1 auto; + position: relative; + min-inline-size: 0; +} + +.size { + flex: none; + text-align: start; + white-space: nowrap; + color: var(--file-upload-file-size-color); +} + +.remove { + flex: none; + block-size: var(--kbq-size-4xl); + inline-size: var(--kbq-size-4xl); + + & > * { + margin: auto; + } +} diff --git a/packages/components/src/components/FileUpload/components/Item/Item.tsx b/packages/components/src/components/FileUpload/components/Item/Item.tsx new file mode 100644 index 000000000..49e7e9a99 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Item/Item.tsx @@ -0,0 +1,332 @@ +'use client'; + +import { forwardRef, useLayoutEffect, useMemo, useRef } from 'react'; +import type { ForwardedRef } from 'react'; + +import { + clsx, + mergeRefs, + mergeProps, + setRef, + useHover, + useFocusRing, + useElementOverflow, +} from '@koobiq/react-core'; +import { IconFileO16, IconCircleXmark16 } from '@koobiq/react-icons'; +import { CollectionNode, createLeafComponent } from '@koobiq/react-primitives'; +import type { Node } from '@react-types/shared'; + +import { IconButton } from '../../../IconButton'; +import { Tooltip } from '../../../Tooltip'; +import { + FileUploadItemContext, + useFileUploadContext, + useFileUploadItemContext, +} from '../../FileUploadContext'; +import { useMiddleEllipsis } from '../../hooks'; +import type { + FileUploadItemProps, + FileUploadItemIconProps, + FileUploadItemNameProps, + FileUploadItemSizeProps, + FileUploadItemContentProps, + FileUploadRemoveButtonProps, +} from '../../types'; +import { isAcceptedFile } from '../../utils'; + +import s from './Item.module.css'; + +class FileUploadItemNode extends CollectionNode { + static readonly type = 'item'; +} + +const toValidationErrors = ( + result: string | string[] | true | null | undefined +): string[] => { + if (!result || result === true) return []; + + return Array.isArray(result) ? result : [result]; +}; + +/** A single row in the file collection. */ +export const FileUploadItem = createLeafComponent( + FileUploadItemNode, + function FileUploadItem( + props: FileUploadItemProps, + ref: ForwardedRef, + node: Node + ) { + const { + children, + className, + style, + file, + isDisabled: isDisabledProp, + isInvalid: isInvalidProp, + 'data-testid': testId, + ...other + } = props; + + const { + accept, + maxFileSize, + validate, + messages, + allowsMultiple, + isDisabled: groupDisabled, + registerItemValidation, + unregisterItemValidation, + } = useFileUploadContext(); + + const validationErrors = useMemo(() => { + if (!file) return []; + + const errors: string[] = []; + + if (!isAcceptedFile(file, accept)) { + errors.push(messages.unsupportedFileType); + } + + if (maxFileSize !== undefined && file.size > maxFileSize) { + errors.push(messages.fileSizeLimitExceeded); + } + + return [...errors, ...toValidationErrors(validate?.(file))]; + }, [ + file, + accept, + maxFileSize, + validate, + messages.unsupportedFileType, + messages.fileSizeLimitExceeded, + ]); + + const isDisabled = groupDisabled || isDisabledProp || false; + const isInvalid = Boolean(isInvalidProp || validationErrors.length > 0); + delete other.id; + delete other.textValue; + + useLayoutEffect(() => { + registerItemValidation( + node.key, + node.textValue || file?.name || '', + validationErrors + ); + + return () => unregisterItemValidation(node.key); + }, [ + file, + node.key, + node.textValue, + validationErrors, + registerItemValidation, + unregisterItemValidation, + ]); + + const { hoverProps, isHovered } = useHover({ isDisabled }); + const { focusProps, isFocusVisible } = useFocusRing({ within: true }); + const rootRef = useRef(null); + + const contextValue = { + id: node.key, + nameText: node.textValue, + isDisabled, + isInvalid, + rootRef, + }; + + return ( + +
+ {children} +
+
+ ); + } +); + +export const FileUploadItemIcon = forwardRef< + HTMLSpanElement, + FileUploadItemIconProps +>((props, ref) => { + const { + children, + className, + style, + 'aria-hidden': ariaHidden, + 'data-testid': testId, + ...other + } = props; + + return ( + + {children ?? } + + ); +}); + +export const FileUploadItemContent = forwardRef< + HTMLSpanElement, + FileUploadItemContentProps +>((props, ref) => { + const { children, className, style, 'data-testid': testId, ...other } = props; + + return ( + + {children} + + ); +}); + +export const FileUploadItemName = forwardRef< + HTMLSpanElement, + FileUploadItemNameProps +>((props, ref) => { + const { children, className, style, 'data-testid': testId, ...other } = props; + const { nameText, rootRef } = useFileUploadItemContext(); + const fileName = typeof children === 'string' ? children : undefined; + + const middleEllipsis = useMiddleEllipsis(fileName ?? ''); + const elementOverflow = useElementOverflow(); + + let displayedName = children; + let isOverflowing = elementOverflow.isOverflowX; + let overflowRef = elementOverflow.ref; + + if (fileName !== undefined) { + displayedName = middleEllipsis.text; + isOverflowing = middleEllipsis.isOverflow; + overflowRef = middleEllipsis.ref; + } + + return ( + ( + { + setRef(ref, element); + setRef(overflowRef, element); + setRef(tooltipRef, element); + }} + style={style} + data-testid={testId} + data-overflowing={isOverflowing || undefined} + className={clsx(s.name, className)} + > + {displayedName} + + )} + > + {nameText ?? children} + + ); +}); + +export const FileUploadItemSize = forwardRef< + HTMLSpanElement, + FileUploadItemSizeProps +>((props, ref) => { + const { children, className, style, 'data-testid': testId, ...other } = props; + const { formatSize } = useFileUploadContext(); + + if (children === undefined || children === null) return null; + + // A non-finite numeric size (NaN/Infinity, e.g. from `Number(undefined)`) + // would make the formatter throw during render, so skip it instead. + if (typeof children === 'number' && !Number.isFinite(children)) return null; + + return ( + + {typeof children === 'number' ? formatSize(children) : children} + + ); +}); + +export const FileUploadRemoveButton = forwardRef< + HTMLButtonElement, + FileUploadRemoveButtonProps +>((props, ref) => { + const { + children, + className, + onPress, + onKeyDown, + 'aria-label': ariaLabel, + ...other + } = props; + + const { messages, removeItem, getItemRef } = useFileUploadContext(); + const { id, nameText, isDisabled, isInvalid } = useFileUploadItemContext(); + + const removeLabel = + ariaLabel ?? `${messages.removeButtonLabel} ${nameText ?? ''}`.trim(); + + return ( + { + onPress?.(event); + removeItem(id); + }} + onKeyDown={(event) => { + onKeyDown?.(event); + + if (event.defaultPrevented) return; + + if (event.key === 'Delete' || event.key === 'Backspace') { + event.preventDefault(); + removeItem(id); + } + }} + > + {children ?? } + + ); +}); + +(FileUploadItem as { displayName?: string }).displayName = 'FileUpload.Item'; +FileUploadItemIcon.displayName = 'FileUpload.ItemIcon'; +FileUploadItemContent.displayName = 'FileUpload.ItemContent'; +FileUploadItemName.displayName = 'FileUpload.ItemName'; +FileUploadItemSize.displayName = 'FileUpload.ItemSize'; +FileUploadRemoveButton.displayName = 'FileUpload.RemoveButton'; diff --git a/packages/components/src/components/FileUpload/components/Item/index.ts b/packages/components/src/components/FileUpload/components/Item/index.ts new file mode 100644 index 000000000..c924835a0 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Item/index.ts @@ -0,0 +1 @@ +export * from './Item'; diff --git a/packages/components/src/components/FileUpload/components/List/List.module.css b/packages/components/src/components/FileUpload/components/List/List.module.css new file mode 100644 index 000000000..17a2fe10c --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/List.module.css @@ -0,0 +1,51 @@ +.base { + display: flex; + overflow: hidden; + inline-size: 100%; + position: relative; + box-sizing: border-box; + flex-direction: column; + justify-content: center; + background-color: var(--file-upload-container-background); + border-radius: var(--file-upload-border-radius); + transition: + border-color var(--kbq-transition-default), + background-color var(--kbq-transition-default); + + &::after { + content: ''; + inset: 0; + position: absolute; + pointer-events: none; + border-radius: inherit; + border: var(--file-upload-border-width) dashed + var(--file-upload-container-border-color); + } +} + +.list { + margin: 0; + display: flex; + overflow-y: visible; + list-style: none; + min-inline-size: 0; + inline-size: 100%; + box-sizing: border-box; + flex-direction: column; + padding-block: 0; + padding-inline: 0; +} + +.list[data-multiple] { + display: grid; + flex: 1 1 auto; + overflow-y: auto; + align-content: start; + grid-template-columns: max-content minmax(0, 1fr) max-content max-content; + scrollbar-width: auto; + scrollbar-gutter: auto; + scrollbar-color: var(--kbq-scrollbar-thumb-default-background) + var(--kbq-background-transparent); + padding-block: var(--kbq-size-xxs) var(--kbq-size-xxl); + min-block-size: var(--file-upload-list-min-block-size); +} diff --git a/packages/components/src/components/FileUpload/components/List/List.tsx b/packages/components/src/components/FileUpload/components/List/List.tsx new file mode 100644 index 000000000..16e4107c4 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/List.tsx @@ -0,0 +1,115 @@ +'use client'; + +import type { ComponentPropsWithRef, ReactNode } from 'react'; +import { forwardRef } from 'react'; + +import { clsx } from '@koobiq/react-core'; + +import { utilClasses } from '../../../../styles/utility'; +import type { FileUploadListSlotProps, FileUploadPropSize } from '../../types'; +import { FileUploadEmpty } from '../Empty'; + +import s from './List.module.css'; +import { FileUploadListAddMore } from './ListAddMore'; +import { FileUploadListEmpty } from './ListEmpty'; + +type FileUploadListProps = Omit, 'children'> & { + children: ReactNode; + slots?: FileUploadListSlotProps; + size: FileUploadPropSize; + isEmpty: boolean; + allowsMultiple: boolean; + showsLargeEmpty: boolean; + renderEmptyState?: () => ReactNode; +}; + +const textNormal = utilClasses.typography['text-normal']; + +const FileUploadListEmptyState = ({ + slots, + showsLargeEmpty, + renderEmptyState, +}: Pick< + FileUploadListProps, + 'slots' | 'showsLargeEmpty' | 'renderEmptyState' +>) => { + if (showsLargeEmpty) { + return renderEmptyState ? renderEmptyState() : ; + } + + return ; +}; + +const FileUploadListFiles = ({ + children, + slots, + size, + allowsMultiple, +}: Pick< + FileUploadListProps, + 'children' | 'slots' | 'size' | 'allowsMultiple' +>) => { + const { className, ...props } = slots?.fileList ?? {}; + + return ( +
+ {children} +
+ ); +}; + +export const FileUploadList = forwardRef( + ( + { + children, + slots, + size, + isEmpty, + allowsMultiple, + showsLargeEmpty, + renderEmptyState, + className, + ...other + }, + ref + ) => { + const contentProps = { + children, + slots, + size, + isEmpty, + allowsMultiple, + showsLargeEmpty, + renderEmptyState, + }; + + let content = ; + + if (!isEmpty) { + content = ( + <> + + {allowsMultiple && } + + ); + } + + return ( +
+ {content} +
+ ); + } +); + +FileUploadList.displayName = 'FileUploadList'; diff --git a/packages/components/src/components/FileUpload/components/List/ListAddMore.module.css b/packages/components/src/components/FileUpload/components/List/ListAddMore.module.css new file mode 100644 index 000000000..08cc2255c --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/ListAddMore.module.css @@ -0,0 +1,24 @@ +.base { + display: flex; + box-sizing: border-box; + align-items: center; + min-inline-size: 0; + gap: var(--file-upload-grid-cell-gap); + padding-block: var(--kbq-size-m); + padding-inline: var(--kbq-size-l); + color: var(--file-upload-caption-color); + border-block-start: var(--file-upload-border-width) dashed + var(--file-upload-footer-border-color); +} + +.icon { + display: flex; + flex: none; + align-items: center; + color: var(--file-upload-dropzone-icon-color); +} + +.text { + min-inline-size: 0; + color: var(--file-upload-caption-color); +} diff --git a/packages/components/src/components/FileUpload/components/List/ListAddMore.tsx b/packages/components/src/components/FileUpload/components/List/ListAddMore.tsx new file mode 100644 index 000000000..a13f30272 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/ListAddMore.tsx @@ -0,0 +1,45 @@ +'use client'; + +import type { ComponentPropsWithRef } from 'react'; +import { forwardRef } from 'react'; + +import { clsx, useDOMRef } from '@koobiq/react-core'; +import { IconArrowUpFromBracket16 } from '@koobiq/react-icons'; + +import { useFileUploadContext } from '../../FileUploadContext'; +import { FileUploadTriggers } from '../Trigger'; + +import s from './ListAddMore.module.css'; + +export const FileUploadListAddMore = forwardRef< + HTMLDivElement, + ComponentPropsWithRef<'div'> +>((props, ref) => { + const { className, style, ...other } = props; + + const { size, messages, isDisabled, allowsMultiple } = useFileUploadContext(); + + const domRef = useDOMRef(ref); + + return ( +
+ + + {messages.addMoreText} {messages.alternativeSeparator}{' '} + + +
+ ); +}); + +FileUploadListAddMore.displayName = 'FileUploadListAddMore'; diff --git a/packages/components/src/components/FileUpload/components/List/ListEmpty.module.css b/packages/components/src/components/FileUpload/components/List/ListEmpty.module.css new file mode 100644 index 000000000..5652985b7 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/ListEmpty.module.css @@ -0,0 +1,20 @@ +.base { + display: flex; + min-inline-size: 0; + align-items: center; + box-sizing: border-box; + gap: var(--file-upload-grid-cell-gap); + color: var(--file-upload-caption-color); +} + +.icon { + flex: none; + display: flex; + align-items: center; + color: var(--file-upload-dropzone-icon-color); +} + +.description { + min-inline-size: 0; + color: var(--file-upload-caption-color); +} diff --git a/packages/components/src/components/FileUpload/components/List/ListEmpty.tsx b/packages/components/src/components/FileUpload/components/List/ListEmpty.tsx new file mode 100644 index 000000000..b8df4707c --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/ListEmpty.tsx @@ -0,0 +1,45 @@ +'use client'; + +import type { ComponentPropsWithRef } from 'react'; +import { forwardRef } from 'react'; + +import { clsx, useDOMRef } from '@koobiq/react-core'; +import { IconArrowUpFromBracket16 } from '@koobiq/react-icons'; + +import { useFileUploadContext } from '../../FileUploadContext'; +import { FileUploadTriggers } from '../Trigger'; + +import s from './ListEmpty.module.css'; + +export const FileUploadListEmpty = forwardRef< + HTMLDivElement, + ComponentPropsWithRef<'div'> +>((props, ref) => { + const { className, style, ...other } = props; + + const { size, messages, isDisabled, allowsMultiple } = useFileUploadContext(); + + const domRef = useDOMRef(ref); + + return ( +
+ + + {messages.listEmptyText} {messages.alternativeSeparator}{' '} + + +
+ ); +}); + +FileUploadListEmpty.displayName = 'FileUploadListEmpty'; diff --git a/packages/components/src/components/FileUpload/components/List/index.ts b/packages/components/src/components/FileUpload/components/List/index.ts new file mode 100644 index 000000000..57363db9a --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/index.ts @@ -0,0 +1,3 @@ +export * from './List'; +export * from './ListAddMore'; +export * from './ListEmpty'; diff --git a/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx b/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx new file mode 100644 index 000000000..6a0f2d590 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { forwardRef } from 'react'; + +import { FileTrigger } from '@koobiq/react-primitives'; + +import { Link } from '../../../Link'; +import { useFileUploadContext } from '../../FileUploadContext'; +import type { FileUploadTriggerProps } from '../../types'; +import { resolveBrowseText } from '../../utils'; + +/** + * Opens the system file dialog. The ref points to the underlying hidden file + * input. + */ +export const FileUploadTrigger = forwardRef< + HTMLInputElement, + FileUploadTriggerProps +>((props, ref) => { + const { + children, + className, + style, + acceptDirectory, + 'data-testid': testId, + } = props; + + const { + accept, + allowed, + isDisabled, + addFiles, + allowsMultiple, + messages, + setTriggerRef, + } = useFileUploadContext(); + + const isDirectory = acceptDirectory ?? allowed === 'folder'; + + let defaultContent = resolveBrowseText(allowed, allowsMultiple, messages); + + if (acceptDirectory !== undefined) { + if (isDirectory) { + defaultContent = messages.browseFolder; + } else if (allowsMultiple) { + defaultContent = messages.browseFiles; + } else { + defaultContent = messages.browseFile; + } + } + + const content = children ?? defaultContent; + + return ( + { + if (files) { + addFiles(Array.from(files)); + } + }} + > + + {content} + + + ); +}); + +export const FileUploadTriggers = () => { + const { allowed, messages } = useFileUploadContext(); + + if (allowed !== 'mixed') return ; + + return ( + <> + + {` ${messages.alternativeSeparator} `} + + {messages.browseFolderMixed} + + + ); +}; + +FileUploadTrigger.displayName = 'FileUpload.Trigger'; diff --git a/packages/components/src/components/FileUpload/components/Trigger/index.ts b/packages/components/src/components/FileUpload/components/Trigger/index.ts new file mode 100644 index 000000000..7247adb3f --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Trigger/index.ts @@ -0,0 +1 @@ +export * from './Trigger'; diff --git a/packages/components/src/components/FileUpload/components/index.ts b/packages/components/src/components/FileUpload/components/index.ts new file mode 100644 index 000000000..98160927f --- /dev/null +++ b/packages/components/src/components/FileUpload/components/index.ts @@ -0,0 +1,4 @@ +export * from './Trigger'; +export * from './Item'; +export * from './Empty'; +export * from './List'; diff --git a/packages/components/src/components/FileUpload/hooks/index.ts b/packages/components/src/components/FileUpload/hooks/index.ts new file mode 100644 index 000000000..1d8ba3948 --- /dev/null +++ b/packages/components/src/components/FileUpload/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useFileDropTarget'; +export * from './useFileUploadField'; +export * from './useMiddleEllipsis'; diff --git a/packages/components/src/components/FileUpload/hooks/useFileDropTarget.ts b/packages/components/src/components/FileUpload/hooks/useFileDropTarget.ts new file mode 100644 index 000000000..809f48deb --- /dev/null +++ b/packages/components/src/components/FileUpload/hooks/useFileDropTarget.ts @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useRef } from 'react'; +import type { RefObject } from 'react'; + +import { useBoolean } from '@koobiq/react-core'; + +import type { FileUploadFile } from '../types'; +import { setFileRelativePath } from '../utils'; + +type UseFileDropTargetOptions = { + ref: RefObject; + isFullscreen?: boolean; + isDisabled: boolean; + onDrop: (files: FileUploadFile[]) => void; +}; + +const readFileEntry = (entry: FileSystemFileEntry): Promise => + new Promise((resolve, reject) => entry.file(resolve, reject)); + +const readDirectoryBatch = ( + reader: FileSystemDirectoryReader +): Promise => + new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + +const readDroppedEntry = async ( + entry: FileSystemEntry, + directoryPath?: string +): Promise => { + if (entry.isFile) { + const file = await readFileEntry(entry as FileSystemFileEntry); + + return [ + setFileRelativePath( + file, + directoryPath ? `${directoryPath}/${entry.name}` : '' + ), + ]; + } + + if (!entry.isDirectory) return []; + + const reader = (entry as FileSystemDirectoryEntry).createReader(); + + const nextDirectoryPath = directoryPath + ? `${directoryPath}/${entry.name}` + : entry.name; + + const entries: FileSystemEntry[] = []; + + while (true) { + const batch = await readDirectoryBatch(reader); + + if (batch.length === 0) break; + + entries.push(...batch); + } + + const groups = await Promise.all( + entries.map((child) => readDroppedEntry(child, nextDirectoryPath)) + ); + + return groups.flat(); +}; + +/** + * Extracts native files from a drop. Dropped folders are expanded recursively; + * non-file items are ignored. + */ +export const readDroppedFiles = async ( + dataTransfer: DataTransfer +): Promise => { + const items = Array.from(dataTransfer.items); + + const groups = await Promise.all( + items.map((item) => { + if (item.kind !== 'file') return Promise.resolve([]); + + const entry = item.webkitGetAsEntry?.(); + + if (entry) return readDroppedEntry(entry); + + const file = item.getAsFile(); + + return Promise.resolve(file ? [setFileRelativePath(file, '')] : []); + }) + ); + + const files = groups.flat(); + + return files.length > 0 + ? files + : Array.from(dataTransfer.files, (file) => + setFileRelativePath(file, file.webkitRelativePath ?? '') + ); +}; + +const isFileDrag = (dataTransfer: DataTransfer | null): boolean => { + if (!dataTransfer) return false; + + return ( + Array.from(dataTransfer.types).includes('Files') || + Array.from(dataTransfer.items).some((item) => item.kind === 'file') || + dataTransfer.files.length > 0 + ); +}; + +export const useFileDropTarget = ({ + ref, + isFullscreen, + isDisabled, + onDrop, +}: UseFileDropTargetOptions): boolean => { + const [isDropTarget, { set: setIsDropTarget }] = useBoolean(false); + const activeTargetRef = useRef(null); + const dragDepthRef = useRef(0); + + const reset = useCallback(() => { + activeTargetRef.current?.removeAttribute('data-drop-target'); + activeTargetRef.current = null; + dragDepthRef.current = 0; + setIsDropTarget(false); + }, [setIsDropTarget]); + + useEffect(() => { + reset(); + + if (isDisabled) return; + + const target = isFullscreen + ? (ref.current?.ownerDocument ?? document).documentElement + : ref.current; + + if (!target) return; + + const handleDragEnter = (event: DragEvent) => { + if (!isFileDrag(event.dataTransfer)) return; + + event.preventDefault(); + event.stopPropagation(); + + dragDepthRef.current += 1; + + if (activeTargetRef.current === target) return; + + activeTargetRef.current = target; + target.setAttribute('data-drop-target', ''); + setIsDropTarget(true); + }; + + const handleDragOver = (event: DragEvent) => { + if (!isFileDrag(event.dataTransfer)) return; + + event.preventDefault(); + event.stopPropagation(); + + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + }; + + const handleDragLeave = (event: DragEvent) => { + if (activeTargetRef.current !== target) return; + + event.stopPropagation(); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + + if (dragDepthRef.current === 0) reset(); + }; + + const handleDrop = (event: DragEvent) => { + if (!isFileDrag(event.dataTransfer) || !event.dataTransfer) return; + + event.preventDefault(); + event.stopPropagation(); + + const { dataTransfer } = event; + + reset(); + + void readDroppedFiles(dataTransfer).then(onDrop); + }; + + target.addEventListener('dragenter', handleDragEnter); + target.addEventListener('dragover', handleDragOver); + target.addEventListener('dragleave', handleDragLeave); + target.addEventListener('drop', handleDrop); + window.addEventListener('dragend', reset); + window.addEventListener('drop', reset); + window.addEventListener('blur', reset); + + return () => { + target.removeEventListener('dragenter', handleDragEnter); + target.removeEventListener('dragover', handleDragOver); + target.removeEventListener('dragleave', handleDragLeave); + target.removeEventListener('drop', handleDrop); + window.removeEventListener('dragend', reset); + window.removeEventListener('drop', reset); + window.removeEventListener('blur', reset); + + if (activeTargetRef.current === target) { + target.removeAttribute('data-drop-target'); + activeTargetRef.current = null; + dragDepthRef.current = 0; + } + }; + }, [ref, isFullscreen, isDisabled, onDrop, reset]); + + return isDropTarget; +}; diff --git a/packages/components/src/components/FileUpload/hooks/useFileUploadField.ts b/packages/components/src/components/FileUpload/hooks/useFileUploadField.ts new file mode 100644 index 000000000..f0ac48118 --- /dev/null +++ b/packages/components/src/components/FileUpload/hooks/useFileUploadField.ts @@ -0,0 +1,95 @@ +import { useMemo } from 'react'; +import type { ComponentPropsWithRef, ReactNode } from 'react'; + +import type { ValidationResult } from '@koobiq/react-core'; +import { useField } from '@koobiq/react-primitives'; + +type FileUploadFieldDOMProps = Pick< + ComponentPropsWithRef<'div'>, + | 'id' + | 'role' + | 'aria-label' + | 'aria-labelledby' + | 'aria-describedby' + | 'aria-details' + | 'aria-errormessage' +>; + +type UseFileUploadFieldOptions = FileUploadFieldDOMProps & { + label?: ReactNode; + caption?: ReactNode; + hasErrorMessage: boolean; + isDisabled: boolean; + isInvalid: boolean; + validationErrors?: string[]; +}; + +const getValidationResult = ( + isInvalid: boolean, + validationErrors: string[] +): ValidationResult => ({ + isInvalid, + validationErrors, + validationDetails: { + badInput: false, + customError: isInvalid, + patternMismatch: false, + rangeOverflow: false, + rangeUnderflow: false, + stepMismatch: false, + tooLong: false, + tooShort: false, + typeMismatch: false, + valueMissing: false, + valid: !isInvalid, + }, +}); + +export const useFileUploadField = ({ + id, + role, + label, + caption, + hasErrorMessage, + isDisabled, + isInvalid, + validationErrors = [], + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + 'aria-details': ariaDetails, + 'aria-errormessage': ariaErrorMessage, +}: UseFileUploadFieldOptions) => { + const { labelProps, fieldProps, descriptionProps, errorMessageProps } = + useField({ + id, + label, + description: caption, + isInvalid, + labelElementType: 'span', + errorMessage: isInvalid && hasErrorMessage, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + }); + + const validation = useMemo( + () => getValidationResult(isInvalid, validationErrors), + [isInvalid, validationErrors] + ); + + return { + validation, + labelProps, + descriptionProps, + errorMessageProps, + controlProps: { + ...fieldProps, + role: role ?? 'group', + 'aria-details': ariaDetails, + 'aria-errormessage': ariaErrorMessage, + 'aria-disabled': isDisabled || undefined, + 'aria-invalid': isInvalid || undefined, + } satisfies ComponentPropsWithRef<'div'>, + }; +}; diff --git a/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.test.tsx b/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.test.tsx new file mode 100644 index 000000000..5e112e998 --- /dev/null +++ b/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.test.tsx @@ -0,0 +1,84 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useMiddleEllipsis } from './useMiddleEllipsis'; + +const TEST_ID = 'element'; + +const createResizeEntry = (width: number): ResizeObserverEntry => + ({ + contentRect: { + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: 20, + width, + height: 20, + }, + borderBoxSize: [{ inlineSize: width, blockSize: 20 }], + }) as unknown as ResizeObserverEntry; + +function TestElement({ value }: { value: string }) { + const { ref, text, isOverflow } = useMiddleEllipsis(value); + + return ( + + {text} + + ); +} + +describe('useMiddleEllipsis', () => { + let resize: ResizeObserverCallback; + + beforeEach(() => { + class ResizeObserverMock { + constructor(callback: ResizeObserverCallback) { + resize = callback; + } + + observe = vi.fn(); + disconnect = vi.fn(); + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + + return 1; + }); + + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + font: '', + measureText: (value: string) => ({ width: value.length * 8 }), + } as CanvasRenderingContext2D); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('keeps both ends of overflowing text and updates on resize', () => { + render(); + + act(() => resize([createResizeEntry(60)], {} as ResizeObserver)); + + expect(screen.getByTestId(TEST_ID)).toHaveTextContent('abc…hij'); + expect(screen.getByTestId(TEST_ID)).toHaveAttribute('data-overflowing'); + + act(() => resize([createResizeEntry(120)], {} as ResizeObserver)); + + expect(screen.getByTestId(TEST_ID)).toHaveTextContent('abcdefghij'); + expect(screen.getByTestId(TEST_ID)).not.toHaveAttribute('data-overflowing'); + }); +}); diff --git a/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.ts b/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.ts new file mode 100644 index 000000000..12f8258b0 --- /dev/null +++ b/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.ts @@ -0,0 +1,99 @@ +'use client'; + +import { useMemo } from 'react'; + +import { useResizeObserver } from '@koobiq/react-core'; + +const ELLIPSIS = '…'; +const contexts = new WeakMap(); + +const getContext = (document: Document): CanvasRenderingContext2D | null => { + const cached = contexts.get(document); + + if (cached) return cached; + + const context = document.createElement('canvas').getContext('2d'); + + if (context) contexts.set(document, context); + + return context; +}; + +const truncateMiddle = ( + value: string, + availableWidth: number, + measure: (text: string) => number +): string => { + const characters = Array.from(value); + const ellipsisWidth = measure(ELLIPSIS); + const partWidth = Math.max(0, (availableWidth - ellipsisWidth) / 2); + + const findLength = (fromEnd: boolean): number => { + let min = 0; + let max = characters.length; + + while (min < max) { + const length = Math.ceil((min + max) / 2); + + const part = fromEnd + ? characters.slice(-length).join('') + : characters.slice(0, length).join(''); + + if (measure(part) <= partWidth) { + min = length; + } else { + max = length - 1; + } + } + + return min; + }; + + const startLength = findLength(false); + const endLength = findLength(true); + + if (startLength + endLength >= characters.length) return value; + + const start = characters.slice(0, startLength).join(''); + const end = endLength > 0 ? characters.slice(-endLength).join('') : ''; + + return `${start}${ELLIPSIS}${end}`; +}; + +export function useMiddleEllipsis( + value: string +) { + const [ref, rect] = useResizeObserver(); + const element = ref.current; + + const result = useMemo(() => { + const availableWidth = element?.clientWidth || rect.width; + + if (!element || availableWidth <= 0 || value.length === 0) { + return { text: value, isOverflow: false }; + } + + const context = getContext(element.ownerDocument); + const view = element.ownerDocument.defaultView; + + if (!context || !view) { + return { text: value, isOverflow: false }; + } + + const style = view.getComputedStyle(element); + + context.font = + style.font || `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + + const measure = (text: string) => context.measureText(text).width; + const isOverflow = measure(value) > availableWidth; + + const text = isOverflow + ? truncateMiddle(value, availableWidth, measure) + : value; + + return { text, isOverflow }; + }, [element, rect.width, value]); + + return { ref, ...result }; +} diff --git a/packages/components/src/components/FileUpload/index.ts b/packages/components/src/components/FileUpload/index.ts new file mode 100644 index 000000000..65cdd6c85 --- /dev/null +++ b/packages/components/src/components/FileUpload/index.ts @@ -0,0 +1,4 @@ +export * from './FileUpload'; +export * from './types'; +export { useFileUploadContext } from './FileUploadContext'; +export type { FileUploadContextValue } from './FileUploadContext'; diff --git a/packages/components/src/components/FileUpload/intl.ts b/packages/components/src/components/FileUpload/intl.ts new file mode 100644 index 000000000..615258413 --- /dev/null +++ b/packages/components/src/components/FileUpload/intl.ts @@ -0,0 +1,32 @@ +export default { + 'ru-RU': { + emptyTitle: 'Перетащите сюда', + listEmptyText: 'Перетащите сюда', + dropOverlayTitle: 'Перетащите сюда', + addMoreText: 'Перетащите ещё', + alternativeSeparator: 'или', + browseFile: 'выберите файл', + browseFiles: 'выберите файлы', + browseFolder: 'выберите папку', + browseFilesOrFolder: 'выберите файлы или папку', + browseFolderMixed: 'папку', + removeButtonLabel: 'Удалить', + unsupportedFileType: 'Неподдерживаемый тип файла', + fileSizeLimitExceeded: 'Превышен допустимый размер файла', + }, + 'en-US': { + emptyTitle: 'Drag here', + listEmptyText: 'Drag here', + dropOverlayTitle: 'Drag here', + addMoreText: 'Drop more', + alternativeSeparator: 'or', + browseFile: 'select a file', + browseFiles: 'select files', + browseFolder: 'select a folder', + browseFilesOrFolder: 'select files or a folder', + browseFolderMixed: 'a folder', + removeButtonLabel: 'Remove', + unsupportedFileType: 'Unsupported file type', + fileSizeLimitExceeded: 'The file size limit has been exceeded', + }, +} as unknown as Record>; diff --git a/packages/components/src/components/FileUpload/types.ts b/packages/components/src/components/FileUpload/types.ts new file mode 100644 index 000000000..33c82dbcc --- /dev/null +++ b/packages/components/src/components/FileUpload/types.ts @@ -0,0 +1,242 @@ +import type { + RefObject, + ReactNode, + ComponentRef, + CSSProperties, + ReactElement, + ComponentPropsWithRef, +} from 'react'; + +import type { + Key, + Validation, + DataAttributeProps, + FileSizeFormatterConfig, + ExtendableComponentPropsWithRef, +} from '@koobiq/react-core'; + +import type { + FormFieldErrorProps, + FormFieldLabelProps, + FormFieldCaptionProps, + FormFieldPropLabelAlign, + FormFieldPropLabelPlacement, +} from '../FormField'; +import { + formFieldPropLabelAlign, + formFieldPropLabelPlacement, +} from '../FormField'; +import type { IconButtonProps } from '../IconButton'; + +export const fileUploadPropSize = ['default', 'compact'] as const; + +export type FileUploadPropSize = (typeof fileUploadPropSize)[number]; + +export const fileUploadPropLabelPlacement = formFieldPropLabelPlacement; + +export type FileUploadPropLabelPlacement = FormFieldPropLabelPlacement; + +export const fileUploadPropLabelAlign = formFieldPropLabelAlign; + +export type FileUploadPropLabelAlign = FormFieldPropLabelAlign; + +export const fileUploadPropAllowed = ['file', 'folder', 'mixed'] as const; + +export type FileUploadPropAllowed = (typeof fileUploadPropAllowed)[number]; + +/** Where files can be dropped: an element ref, or the whole viewport. */ +export type FileUploadPropDropzoneTarget = + | 'fullscreen' + | RefObject; + +/** Localizable strings used by `FileUpload`. */ +export type FileUploadMessages = { + emptyTitle: string; + listEmptyText: string; + dropOverlayTitle: string; + addMoreText: string; + alternativeSeparator: string; + browseFile: string; + browseFiles: string; + browseFolder: string; + browseFilesOrFolder: string; + browseFolderMixed: string; + removeButtonLabel: string; + unsupportedFileType: string; + fileSizeLimitExceeded: string; +}; + +export type FileUploadListSlotProps = { + emptyState?: ComponentPropsWithRef<'div'>; + fileList?: ComponentPropsWithRef<'div'>; + addMore?: ComponentPropsWithRef<'div'>; +}; + +/** A native file with its path relative to the selected or dropped directory. */ +export interface FileUploadFile extends File { + readonly relativePath: string; +} + +export type FileUploadValidate = NonNullable< + Validation['validate'] +>; + +export type FileUploadProps = + ExtendableComponentPropsWithRef< + { + /** The contents of the collection. */ + children: ReactNode | ((item: T) => ReactNode); + /** Item objects in the collection. */ + items?: Iterable; + /** Handler called when native files are added via picker or drop. */ + onAdd?: (files: FileUploadFile[]) => void; + /** Validates native files associated with `FileUpload.Item`. */ + validate?: FileUploadValidate; + /** Handler called when a remove button requests item removal. */ + onRemove?: (id: Key) => void; + /** Where files can be dropped: a target element ref or 'fullscreen'. Defaults to the FileUpload root. */ + dropzoneTarget?: FileUploadPropDropzoneTarget; + /** Custom large empty state renderer. Return `null` to hide the empty state. */ + renderEmptyState?: () => ReactNode; + /** Overrides localized text used by FileUpload. */ + messages?: Partial; + /** Configures how file sizes are formatted (unit system, precision, unit labels). */ + fileSizeFormat?: FileSizeFormatterConfig; + /** The field label. */ + label?: ReactNode; + /** Helper text displayed below the upload area. */ + caption?: ReactNode; + /** Validation error displayed when `isInvalid` is true. */ + errorMessage?: FormFieldErrorProps['children']; + /** Whether the label is visually hidden. */ + isLabelHidden?: boolean; + /** Whether the field is marked as required. */ + isRequired?: boolean; + /** + * The label's position relative to the upload area. + * @default 'top' + */ + labelPlacement?: FileUploadPropLabelPlacement; + /** + * The label's horizontal alignment. + * @default 'start' + */ + labelAlign?: FileUploadPropLabelAlign; + /** + * Whether more than one file can be selected. + * @default false + */ + allowsMultiple?: boolean; + /** File type specifiers used by the picker and item validation. */ + accept?: string[]; + /** Maximum allowed size of an individual file, in bytes. */ + maxFileSize?: number; + /** + * Which kind of items can be selected or dropped. + * @default 'file' + */ + allowed?: FileUploadPropAllowed; + /** + * The layout size. + * @default 'default' + */ + size?: FileUploadPropSize; + /** Whether the whole component is disabled. */ + isDisabled?: boolean; + /** Whether the whole component is in the invalid state. */ + isInvalid?: boolean; + /** Props applied to internal parts of FileUpload. */ + slotProps?: { + list?: FileUploadListSlotProps; + dropOverlay?: ComponentPropsWithRef<'div'>; + label?: FormFieldLabelProps<'span'>; + caption?: FormFieldCaptionProps; + errorMessage?: Omit; + }; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; + }, + 'div' + >; + +export type FileUploadRef = ComponentRef<'div'>; + +export type FileUploadEmptyProps = ExtendableComponentPropsWithRef< + DataAttributeProps, + 'div' +>; + +export type FileUploadEmptyIconProps = ExtendableComponentPropsWithRef< + DataAttributeProps, + 'span' +>; + +export type FileUploadEmptyTitleProps = ExtendableComponentPropsWithRef< + DataAttributeProps, + 'span' +>; + +export type FileUploadEmptyDescriptionProps = ExtendableComponentPropsWithRef< + DataAttributeProps, + 'span' +>; + +export type FileUploadTriggerProps = { + /** + * The contents of the browse link. + */ + children?: ReactNode; + /** + * Open the folder picker (`webkitdirectory`) instead of the file picker. + * @default allowed === 'folder' + */ + acceptDirectory?: boolean; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; +}; + +export type FileUploadItemProps = ExtendableComponentPropsWithRef< + { + /** Stable unique key of this item. */ + id?: Key; + /** Text used by collection accessibility and the remove button label. */ + textValue?: string; + /** Whether this row is disabled. */ + isDisabled?: boolean; + /** Whether this row is invalid. */ + isInvalid?: boolean; + /** Native file used for automatic item validation. */ + file?: FileUploadFile; + } & DataAttributeProps, + 'div' +>; + +export type FileUploadItemIconProps = ExtendableComponentPropsWithRef< + DataAttributeProps, + 'span' +>; + +export type FileUploadItemContentProps = ExtendableComponentPropsWithRef< + DataAttributeProps, + 'span' +>; + +export type FileUploadItemNameProps = ExtendableComponentPropsWithRef< + DataAttributeProps, + 'span' +>; + +export type FileUploadItemSizeProps = ExtendableComponentPropsWithRef< + DataAttributeProps, + 'span' +>; + +export type FileUploadRemoveButtonProps = IconButtonProps; + +export type FileUploadComponent = ( + props: FileUploadProps +) => ReactElement | null; diff --git a/packages/components/src/components/FileUpload/utils.ts b/packages/components/src/components/FileUpload/utils.ts new file mode 100644 index 000000000..d640a3368 --- /dev/null +++ b/packages/components/src/components/FileUpload/utils.ts @@ -0,0 +1,113 @@ +import type { + Key, + Node, + Collection as AriaCollection, +} from '@koobiq/react-core'; + +import type { + FileUploadFile, + FileUploadMessages, + FileUploadPropAllowed, +} from './types'; + +type PrepareFilesOptions = { + allowed: FileUploadPropAllowed; + allowsMultiple: boolean; +}; + +const normalizeRelativePath = (path: string): string => + path.replaceAll('\\', '/').replace(/^\/+/, ''); + +export const getCollectionKeys = ( + collection: AriaCollection> +): Key[] => Array.from(collection, (node) => node.key); + +export const setFileRelativePath = ( + file: File, + path: string +): FileUploadFile => { + const relativePath = normalizeRelativePath(path); + + if ((file as Partial).relativePath === relativePath) { + return file as FileUploadFile; + } + + Object.defineProperty(file, 'relativePath', { + configurable: true, + value: relativePath, + }); + + return file as FileUploadFile; +}; + +const getRootDirectory = (file: FileUploadFile): string | undefined => + file.relativePath.split('/').filter(Boolean)[0]; + +export const isAcceptedFile = (file: File, accept?: string[]): boolean => { + const values = accept + ?.map((value) => value.trim().toLowerCase()) + .filter(Boolean); + + if (!values?.length) return true; + + const name = file.name.toLowerCase(); + const type = file.type.toLowerCase(); + + return values.some((value) => { + if (value === '*/*') return true; + if (value.startsWith('.')) return name.endsWith(value); + if (value.endsWith('/*')) return type.startsWith(value.slice(0, -1)); + + return type === value; + }); +}; + +/** Normalizes files and applies selection-mode constraints. */ +export const prepareFileUploadFiles = ( + files: File[], + { allowed, allowsMultiple }: PrepareFilesOptions +): FileUploadFile[] => { + const normalized = files.map((file) => + setFileRelativePath( + file, + (file as Partial).relativePath ?? + file.webkitRelativePath ?? + '' + ) + ); + + const filtered = normalized.filter((file) => { + const isDirectoryFile = Boolean(getRootDirectory(file)); + + const isAllowed = + allowed === 'mixed' || + (allowed === 'folder' ? isDirectoryFile : !isDirectoryFile); + + return isAllowed; + }); + + if (allowsMultiple || filtered.length === 0) return filtered; + + const rootDirectory = getRootDirectory(filtered[0]); + + return rootDirectory + ? filtered.filter((file) => getRootDirectory(file) === rootDirectory) + : filtered.slice(0, 1); +}; + +/** The browse-link label for the current selection mode. */ +export const resolveBrowseText = ( + allowed: FileUploadPropAllowed, + allowsMultiple: boolean, + messages: FileUploadMessages +): string => { + if (allowed === 'folder') { + return messages.browseFolder; + } + + if (allowed === 'mixed') { + return messages.browseFilesOrFolder; + } + + return allowsMultiple ? messages.browseFiles : messages.browseFile; +}; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 81d735bc6..5a00d1d64 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -57,6 +57,7 @@ export * from './EmptyState'; export * from './Flag'; export * from './Sidebar'; export * from './Resizable'; +export * from './FileUpload'; export * from './layout'; export { useListData, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fbc57cc88..2c4548b43 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,6 +16,7 @@ export type { Key, Node, ItemProps, + Collection, PressEvent, HoverEvent, Validation, diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index d9e513332..39fa778ed 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -139,6 +139,7 @@ export { TreeItem, TreeContext, Collection, + FileTrigger, TreeItemContent, ButtonContext, TreeStateContext, @@ -151,6 +152,8 @@ export { CollectionRendererContext, } from 'react-aria-components'; +export type { FileTriggerProps } from 'react-aria-components'; + export * from './behaviors'; export * from './components'; export { removeDataAttributes } from './utils'; diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 8484bfc6c..babae623b 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -18,6 +18,7 @@ "DatePicker", "Divider", "EmptyState", + "FileUpload", "Flag", "FlexBox", "Form", diff --git a/tools/public_api_guard/components/FileUpload.api.md b/tools/public_api_guard/components/FileUpload.api.md new file mode 100644 index 000000000..e10131248 --- /dev/null +++ b/tools/public_api_guard/components/FileUpload.api.md @@ -0,0 +1,218 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ButtonBaseProps } from '@koobiq/react-primitives'; +import type { ComponentPropsWithRef } from 'react'; +import type { ComponentRef } from 'react'; +import type { CSSProperties } from 'react'; +import { DataAttributeProps } from '@koobiq/react-core'; +import { DetailedHTMLProps } from 'react'; +import type { ElementType } from 'react'; +import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; +import type { ExtendableProps } from '@koobiq/react-core'; +import type { FileSizeFormatterConfig } from '@koobiq/react-core'; +import { ForwardRefExoticComponent } from 'react'; +import { HTMLAttributes } from 'react'; +import type { Key } from '@koobiq/react-core'; +import { Key as Key_2 } from 'react-aria'; +import { PolyForwardComponent } from '@koobiq/react-core'; +import { ReactElement } from 'react'; +import { ReactNode } from 'react'; +import { RefAttributes } from 'react'; +import type { RefObject } from 'react'; +import { TextProps } from '@koobiq/react-primitives'; +import type { Validation } from '@koobiq/react-core'; +import { ValidationResult } from '@koobiq/react-core'; + +// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts +// +// @public +export const FileUpload: CompoundedComponent; + +// @public (undocumented) +export type FileUploadComponent = (props: FileUploadProps) => ReactElement | null; + +// @public (undocumented) +export type FileUploadContextValue = { + allowsMultiple: boolean; + accept?: string[]; + maxFileSize?: number; + validate?: FileUploadValidate; + allowed: FileUploadPropAllowed; + size: FileUploadPropSize; + isDisabled: boolean; + isDropTarget: boolean; + addFiles: (files: File[]) => void; + removeItem: (id: Key) => void; + getItemRef: (id: Key) => RefObject; + setTriggerRef: (element: HTMLElement | null) => void; + registerItemValidation: (id: Key, name: string, errors: string[]) => void; + unregisterItemValidation: (id: Key) => void; + messages: FileUploadMessages; + formatSize: (bytes: number) => string; +}; + +// @public (undocumented) +export type FileUploadEmptyDescriptionProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadEmptyIconProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadEmptyProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadEmptyTitleProps = ExtendableComponentPropsWithRef; + +// @public +export interface FileUploadFile extends File { + // (undocumented) + readonly relativePath: string; +} + +// @public (undocumented) +export type FileUploadItemContentProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadItemIconProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadItemNameProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadItemProps = ExtendableComponentPropsWithRef<{ + id?: Key; + textValue?: string; + isDisabled?: boolean; + isInvalid?: boolean; + file?: FileUploadFile; +} & DataAttributeProps, 'div'>; + +// @public (undocumented) +export type FileUploadItemSizeProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadListSlotProps = { + emptyState?: ComponentPropsWithRef<'div'>; + fileList?: ComponentPropsWithRef<'div'>; + addMore?: ComponentPropsWithRef<'div'>; +}; + +// @public +export type FileUploadMessages = { + emptyTitle: string; + listEmptyText: string; + dropOverlayTitle: string; + addMoreText: string; + alternativeSeparator: string; + browseFile: string; + browseFiles: string; + browseFolder: string; + browseFilesOrFolder: string; + browseFolderMixed: string; + removeButtonLabel: string; + unsupportedFileType: string; + fileSizeLimitExceeded: string; +}; + +// @public (undocumented) +export type FileUploadPropAllowed = (typeof fileUploadPropAllowed)[number]; + +// @public (undocumented) +export const fileUploadPropAllowed: readonly ["file", "folder", "mixed"]; + +// @public +export type FileUploadPropDropzoneTarget = 'fullscreen' | RefObject; + +// Warning: (ae-forgotten-export) The symbol "FormFieldPropLabelAlign" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type FileUploadPropLabelAlign = FormFieldPropLabelAlign; + +// @public (undocumented) +export const fileUploadPropLabelAlign: readonly ["start", "end"]; + +// Warning: (ae-forgotten-export) The symbol "FormFieldPropLabelPlacement" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type FileUploadPropLabelPlacement = FormFieldPropLabelPlacement; + +// @public (undocumented) +export const fileUploadPropLabelPlacement: readonly ["top", "side"]; + +// @public (undocumented) +export type FileUploadProps = ExtendableComponentPropsWithRef<{ + children: ReactNode | ((item: T) => ReactNode); + items?: Iterable; + onAdd?: (files: FileUploadFile[]) => void; + validate?: FileUploadValidate; + onRemove?: (id: Key) => void; + dropzoneTarget?: FileUploadPropDropzoneTarget; + renderEmptyState?: () => ReactNode; + messages?: Partial; + fileSizeFormat?: FileSizeFormatterConfig; + label?: ReactNode; + caption?: ReactNode; + errorMessage?: FormFieldErrorProps['children']; + isLabelHidden?: boolean; + isRequired?: boolean; + labelPlacement?: FileUploadPropLabelPlacement; + labelAlign?: FileUploadPropLabelAlign; + allowsMultiple?: boolean; + accept?: string[]; + maxFileSize?: number; + allowed?: FileUploadPropAllowed; + size?: FileUploadPropSize; + isDisabled?: boolean; + isInvalid?: boolean; + slotProps?: { + list?: FileUploadListSlotProps; + dropOverlay?: ComponentPropsWithRef<'div'>; + label?: FormFieldLabelProps<'span'>; + caption?: FormFieldCaptionProps; + errorMessage?: Omit; + }; + 'data-testid'?: string | number; +}, 'div'>; + +// @public (undocumented) +export type FileUploadPropSize = (typeof fileUploadPropSize)[number]; + +// @public (undocumented) +export const fileUploadPropSize: readonly ["default", "compact"]; + +// @public (undocumented) +export type FileUploadRef = ComponentRef<'div'>; + +// Warning: (ae-forgotten-export) The symbol "IconButtonProps" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type FileUploadRemoveButtonProps = IconButtonProps; + +// @public (undocumented) +export type FileUploadTriggerProps = { + children?: ReactNode; + acceptDirectory?: boolean; + className?: string; + style?: CSSProperties; + 'data-testid'?: string | number; +}; + +// @public (undocumented) +export type FileUploadValidate = NonNullable['validate']>; + +// @public (undocumented) +export const useFileUploadContext: () => FileUploadContextValue; + +// Warnings were encountered during analysis: +// +// packages/components/dist/components/FileUpload/types.d.ts:65:5 - (ae-forgotten-export) The symbol "FormFieldErrorProps" needs to be exported by the entry point index.d.ts +// packages/components/dist/components/FileUpload/types.d.ts:107:9 - (ae-forgotten-export) The symbol "FormFieldLabelProps" needs to be exported by the entry point index.d.ts +// packages/components/dist/components/FileUpload/types.d.ts:108:9 - (ae-forgotten-export) The symbol "FormFieldCaptionProps" needs to be exported by the entry point index.d.ts + +// (No @packageDocumentation comment for this package) + +``` diff --git a/tools/public_api_guard/react-core.api.md b/tools/public_api_guard/react-core.api.md index 427316b33..8662b67fc 100644 --- a/tools/public_api_guard/react-core.api.md +++ b/tools/public_api_guard/react-core.api.md @@ -7,6 +7,7 @@ import { AriaLabelingProps } from '@react-types/shared'; import { AsyncLoadable } from '@react-types/shared'; import { chain } from '@react-aria/utils'; +import { Collection } from '@react-types/shared'; import { CollectionBase } from '@react-types/shared'; import { CollectionChildren } from '@react-types/shared'; import { CollectionElement } from '@react-types/shared'; @@ -77,6 +78,8 @@ export { chain } // @public (undocumented) export function clsx(...classNames: ClassName[]): string; +export { Collection } + export { CollectionBase } export { CollectionChildren } diff --git a/tools/public_api_guard/react-primitives.api.md b/tools/public_api_guard/react-primitives.api.md index cb65ac708..a40904597 100644 --- a/tools/public_api_guard/react-primitives.api.md +++ b/tools/public_api_guard/react-primitives.api.md @@ -68,6 +68,8 @@ import { DragEventHandler } from 'react'; import { ElementType } from 'react'; import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; import type { ExtendableProps } from '@koobiq/react-core'; +import { FileTrigger } from 'react-aria-components'; +import { FileTriggerProps } from 'react-aria-components'; import { FocusableElement } from '@react-types/shared'; import { FocusableProps } from '@koobiq/react-core'; import { FocusEventHandler } from 'react'; @@ -325,6 +327,10 @@ export type FieldErrorProps = ComponentPropsWithRe // @public (undocumented) export type FieldErrorRenderProps = ValidationResult; +export { FileTrigger } + +export { FileTriggerProps } + // @public export const Form: ForwardRefExoticComponent>;