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 (
+
+
+
+
+ );
+ },
+};
+
+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>;