2025-08-04 17:45:44 +02:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { Slot } from "@radix-ui/react-slot";
|
|
|
|
|
import {
|
2025-08-28 18:27:07 +02:00
|
|
|
FileArchiveIcon,
|
|
|
|
|
FileAudioIcon,
|
|
|
|
|
FileCodeIcon,
|
|
|
|
|
FileCogIcon,
|
|
|
|
|
FileIcon,
|
|
|
|
|
FileTextIcon,
|
|
|
|
|
FileVideoIcon,
|
2025-08-04 17:45:44 +02:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import {
|
2025-08-28 18:27:07 +02:00
|
|
|
type ChangeEvent,
|
|
|
|
|
type ComponentPropsWithoutRef,
|
|
|
|
|
createContext,
|
|
|
|
|
type DragEvent,
|
|
|
|
|
forwardRef,
|
|
|
|
|
type KeyboardEvent,
|
|
|
|
|
type MouseEvent,
|
|
|
|
|
type ReactNode,
|
|
|
|
|
type RefObject,
|
|
|
|
|
useCallback,
|
|
|
|
|
useContext,
|
|
|
|
|
useEffect,
|
|
|
|
|
useId,
|
|
|
|
|
useLayoutEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useSyncExternalStore,
|
2025-08-04 17:45:44 +02:00
|
|
|
} from "react";
|
2025-08-28 18:27:07 +02:00
|
|
|
import { cn } from "~/lib/utils";
|
2025-08-04 17:45:44 +02:00
|
|
|
|
|
|
|
|
const ROOT_NAME = "FileUpload";
|
|
|
|
|
const DROPZONE_NAME = "FileUploadDropzone";
|
|
|
|
|
const TRIGGER_NAME = "FileUploadTrigger";
|
|
|
|
|
const LIST_NAME = "FileUploadList";
|
|
|
|
|
const ITEM_NAME = "FileUploadItem";
|
|
|
|
|
const ITEM_PREVIEW_NAME = "FileUploadItemPreview";
|
|
|
|
|
const ITEM_METADATA_NAME = "FileUploadItemMetadata";
|
|
|
|
|
const ITEM_PROGRESS_NAME = "FileUploadItemProgress";
|
|
|
|
|
const ITEM_DELETE_NAME = "FileUploadItemDelete";
|
|
|
|
|
const CLEAR_NAME = "FileUploadClear";
|
|
|
|
|
|
|
|
|
|
const FILE_UPLOAD_ERRORS = {
|
2025-08-28 18:27:07 +02:00
|
|
|
[ROOT_NAME]: `\`${ROOT_NAME}\` must be used as root component`,
|
|
|
|
|
[DROPZONE_NAME]: `\`${DROPZONE_NAME}\` must be within \`${ROOT_NAME}\``,
|
|
|
|
|
[TRIGGER_NAME]: `\`${TRIGGER_NAME}\` must be within \`${ROOT_NAME}\``,
|
|
|
|
|
[LIST_NAME]: `\`${LIST_NAME}\` must be within \`${ROOT_NAME}\``,
|
|
|
|
|
[ITEM_NAME]: `\`${ITEM_NAME}\` must be within \`${ROOT_NAME}\``,
|
|
|
|
|
[ITEM_PREVIEW_NAME]: `\`${ITEM_PREVIEW_NAME}\` must be within \`${ITEM_NAME}\``,
|
|
|
|
|
[ITEM_METADATA_NAME]: `\`${ITEM_METADATA_NAME}\` must be within \`${ITEM_NAME}\``,
|
|
|
|
|
[ITEM_PROGRESS_NAME]: `\`${ITEM_PROGRESS_NAME}\` must be within \`${ITEM_NAME}\``,
|
|
|
|
|
[ITEM_DELETE_NAME]: `\`${ITEM_DELETE_NAME}\` must be within \`${ITEM_NAME}\``,
|
|
|
|
|
[CLEAR_NAME]: `\`${CLEAR_NAME}\` must be within \`${ROOT_NAME}\``,
|
2025-08-04 17:45:44 +02:00
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
const useIsomorphicLayoutEffect =
|
2025-08-28 18:27:07 +02:00
|
|
|
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
2025-08-04 17:45:44 +02:00
|
|
|
|
|
|
|
|
function useAsRef<T>(data: T) {
|
2025-08-28 18:27:07 +02:00
|
|
|
const ref = useRef<T>(data);
|
|
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
|
|
|
ref.current = data;
|
|
|
|
|
});
|
|
|
|
|
return ref;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useLazyRef<T>(fn: () => T) {
|
2025-08-28 18:27:07 +02:00
|
|
|
const ref = useRef<T | null>(null);
|
|
|
|
|
if (ref.current === null) {
|
|
|
|
|
ref.current = fn();
|
|
|
|
|
}
|
|
|
|
|
return ref as RefObject<T>;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Direction = "ltr" | "rtl";
|
|
|
|
|
|
|
|
|
|
const DirectionContext = createContext<Direction | undefined>(undefined);
|
|
|
|
|
|
|
|
|
|
function useDirection(dirProp?: Direction): Direction {
|
2025-08-28 18:27:07 +02:00
|
|
|
const contextDir = useContext(DirectionContext);
|
|
|
|
|
return dirProp ?? contextDir ?? "ltr";
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FileState {
|
2025-08-28 18:27:07 +02:00
|
|
|
file: File;
|
|
|
|
|
progress: number;
|
|
|
|
|
error?: string;
|
|
|
|
|
status: "idle" | "uploading" | "error" | "success";
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface StoreState {
|
2025-08-28 18:27:07 +02:00
|
|
|
files: Map<File, FileState>;
|
|
|
|
|
dragOver: boolean;
|
|
|
|
|
invalid: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type StoreAction =
|
2025-08-28 18:27:07 +02:00
|
|
|
| { variant: "ADD_FILES"; files: File[] }
|
|
|
|
|
| { variant: "SET_FILES"; files: File[] }
|
|
|
|
|
| { variant: "SET_PROGRESS"; file: File; progress: number }
|
|
|
|
|
| { variant: "SET_SUCCESS"; file: File }
|
|
|
|
|
| { variant: "SET_ERROR"; file: File; error: string }
|
|
|
|
|
| { variant: "REMOVE_FILE"; file: File }
|
|
|
|
|
| { variant: "SET_DRAG_OVER"; dragOver: boolean }
|
|
|
|
|
| { variant: "SET_INVALID"; invalid: boolean }
|
|
|
|
|
| { variant: "CLEAR" };
|
2025-08-04 17:45:44 +02:00
|
|
|
|
|
|
|
|
function createStore(
|
2025-08-28 18:27:07 +02:00
|
|
|
listeners: Set<() => void>,
|
|
|
|
|
files: Map<File, FileState>,
|
|
|
|
|
onValueChange?: (files: File[]) => void,
|
|
|
|
|
invalid?: boolean,
|
2025-08-04 17:45:44 +02:00
|
|
|
) {
|
2025-08-28 18:27:07 +02:00
|
|
|
const initialState: StoreState = {
|
|
|
|
|
dragOver: false,
|
2025-08-29 16:18:32 +02:00
|
|
|
files,
|
2025-08-28 18:27:07 +02:00
|
|
|
invalid: invalid ?? false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let state = initialState;
|
|
|
|
|
|
|
|
|
|
function reducer(state: StoreState, action: StoreAction): StoreState {
|
|
|
|
|
switch (action.variant) {
|
|
|
|
|
case "ADD_FILES": {
|
|
|
|
|
for (const file of action.files) {
|
|
|
|
|
files.set(file, {
|
|
|
|
|
file,
|
|
|
|
|
progress: 0,
|
|
|
|
|
status: "idle",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (onValueChange) {
|
|
|
|
|
const fileList = Array.from(files.values()).map(
|
|
|
|
|
(fileState) => fileState.file,
|
|
|
|
|
);
|
|
|
|
|
onValueChange(fileList);
|
|
|
|
|
}
|
|
|
|
|
return { ...state, files };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "SET_FILES": {
|
|
|
|
|
const newFileSet = new Set(action.files);
|
|
|
|
|
for (const existingFile of files.keys()) {
|
|
|
|
|
if (!newFileSet.has(existingFile)) {
|
|
|
|
|
files.delete(existingFile);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const file of action.files) {
|
|
|
|
|
const existingState = files.get(file);
|
|
|
|
|
if (!existingState) {
|
|
|
|
|
files.set(file, {
|
|
|
|
|
file,
|
|
|
|
|
progress: 0,
|
|
|
|
|
status: "idle",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { ...state, files };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "SET_PROGRESS": {
|
|
|
|
|
const fileState = files.get(action.file);
|
|
|
|
|
if (fileState) {
|
|
|
|
|
files.set(action.file, {
|
|
|
|
|
...fileState,
|
|
|
|
|
progress: action.progress,
|
|
|
|
|
status: "uploading",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return { ...state, files };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "SET_SUCCESS": {
|
|
|
|
|
const fileState = files.get(action.file);
|
|
|
|
|
if (fileState) {
|
|
|
|
|
files.set(action.file, {
|
|
|
|
|
...fileState,
|
|
|
|
|
progress: 100,
|
|
|
|
|
status: "success",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return { ...state, files };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "SET_ERROR": {
|
|
|
|
|
const fileState = files.get(action.file);
|
|
|
|
|
if (fileState) {
|
|
|
|
|
files.set(action.file, {
|
|
|
|
|
...fileState,
|
|
|
|
|
error: action.error,
|
|
|
|
|
status: "error",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return { ...state, files };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "REMOVE_FILE": {
|
|
|
|
|
files.delete(action.file);
|
|
|
|
|
|
|
|
|
|
if (onValueChange) {
|
|
|
|
|
const fileList = Array.from(files.values()).map(
|
|
|
|
|
(fileState) => fileState.file,
|
|
|
|
|
);
|
|
|
|
|
onValueChange(fileList);
|
|
|
|
|
}
|
|
|
|
|
return { ...state, files };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "SET_DRAG_OVER": {
|
|
|
|
|
return { ...state, dragOver: action.dragOver };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "SET_INVALID": {
|
|
|
|
|
return { ...state, invalid: action.invalid };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "CLEAR": {
|
|
|
|
|
files.clear();
|
|
|
|
|
if (onValueChange) {
|
|
|
|
|
onValueChange([]);
|
|
|
|
|
}
|
|
|
|
|
return { ...state, files, invalid: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getState() {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function dispatch(action: StoreAction) {
|
|
|
|
|
state = reducer(state, action);
|
|
|
|
|
for (const listener of listeners) {
|
|
|
|
|
listener();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function subscribe(listener: () => void) {
|
|
|
|
|
listeners.add(listener);
|
|
|
|
|
return () => listeners.delete(listener);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-29 16:18:32 +02:00
|
|
|
return { dispatch, getState, subscribe };
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const StoreContext = createContext<ReturnType<typeof createStore> | null>(null);
|
|
|
|
|
StoreContext.displayName = ROOT_NAME;
|
|
|
|
|
|
|
|
|
|
function useStoreContext(name: keyof typeof FILE_UPLOAD_ERRORS) {
|
2025-08-28 18:27:07 +02:00
|
|
|
const context = useContext(StoreContext);
|
|
|
|
|
if (!context) {
|
|
|
|
|
throw new Error(FILE_UPLOAD_ERRORS[name]);
|
|
|
|
|
}
|
|
|
|
|
return context;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useStore<T>(selector: (state: StoreState) => T): T {
|
2025-08-28 18:27:07 +02:00
|
|
|
const store = useStoreContext(ROOT_NAME);
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
const lastValueRef = useLazyRef<{ value: T; state: StoreState } | null>(
|
|
|
|
|
() => null,
|
|
|
|
|
);
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
const getSnapshot = useCallback(() => {
|
|
|
|
|
const state = store.getState();
|
|
|
|
|
const prevValue = lastValueRef.current;
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
if (prevValue && prevValue.state === state) {
|
|
|
|
|
return prevValue.value;
|
|
|
|
|
}
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
const nextValue = selector(state);
|
2025-08-29 16:18:32 +02:00
|
|
|
lastValueRef.current = { state, value: nextValue };
|
2025-08-28 18:27:07 +02:00
|
|
|
return nextValue;
|
|
|
|
|
}, [store, selector, lastValueRef]);
|
2025-08-04 17:45:44 +02:00
|
|
|
|
2025-08-28 18:27:07 +02:00
|
|
|
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FileUploadContextValue {
|
2025-08-28 18:27:07 +02:00
|
|
|
inputId: string;
|
|
|
|
|
dropzoneId: string;
|
|
|
|
|
listId: string;
|
|
|
|
|
labelId: string;
|
|
|
|
|
disabled: boolean;
|
|
|
|
|
dir: Direction;
|
|
|
|
|
inputRef: RefObject<HTMLInputElement | null>;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadContext = createContext<FileUploadContextValue | null>(null);
|
|
|
|
|
|
|
|
|
|
function useFileUploadContext(name: keyof typeof FILE_UPLOAD_ERRORS) {
|
2025-08-28 18:27:07 +02:00
|
|
|
const context = useContext(FileUploadContext);
|
|
|
|
|
if (!context) {
|
|
|
|
|
throw new Error(FILE_UPLOAD_ERRORS[name]);
|
|
|
|
|
}
|
|
|
|
|
return context;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FileUploadRootProps
|
2025-08-28 18:27:07 +02:00
|
|
|
extends Omit<ComponentPropsWithoutRef<"div">, "defaultValue" | "onChange"> {
|
|
|
|
|
value?: File[];
|
|
|
|
|
defaultValue?: File[];
|
|
|
|
|
onValueChange?: (files: File[]) => void;
|
|
|
|
|
onAccept?: (files: File[]) => void;
|
|
|
|
|
onFileAccept?: (file: File) => void;
|
|
|
|
|
onFileReject?: (file: File, message: string) => void;
|
|
|
|
|
onFileValidate?: (file: File) => string | null | undefined;
|
|
|
|
|
onUpload?: (
|
|
|
|
|
files: File[],
|
|
|
|
|
options: {
|
|
|
|
|
onProgress: (file: File, progress: number) => void;
|
|
|
|
|
onSuccess: (file: File) => void;
|
|
|
|
|
onError: (file: File, error: Error) => void;
|
|
|
|
|
},
|
|
|
|
|
) => Promise<void> | void;
|
|
|
|
|
accept?: string;
|
|
|
|
|
maxFiles?: number;
|
|
|
|
|
maxSize?: number;
|
|
|
|
|
dir?: Direction;
|
|
|
|
|
label?: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
asChild?: boolean;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
invalid?: boolean;
|
|
|
|
|
multiple?: boolean;
|
|
|
|
|
required?: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadRoot = forwardRef<HTMLDivElement, FileUploadRootProps>(
|
2025-08-28 18:27:07 +02:00
|
|
|
(props, forwardedRef) => {
|
|
|
|
|
const {
|
|
|
|
|
value,
|
|
|
|
|
defaultValue,
|
|
|
|
|
onValueChange,
|
|
|
|
|
//onAccept,
|
|
|
|
|
//onFileAccept,
|
|
|
|
|
//onFileReject,
|
|
|
|
|
//onFileValidate,
|
|
|
|
|
//onUpload,
|
|
|
|
|
accept,
|
|
|
|
|
//maxFiles,
|
|
|
|
|
//maxSize,
|
|
|
|
|
dir: dirProp,
|
|
|
|
|
label,
|
|
|
|
|
name,
|
|
|
|
|
asChild,
|
|
|
|
|
disabled = false,
|
|
|
|
|
invalid = false,
|
|
|
|
|
multiple = false,
|
|
|
|
|
required = false,
|
|
|
|
|
children,
|
|
|
|
|
className,
|
|
|
|
|
...rootProps
|
|
|
|
|
} = props;
|
|
|
|
|
|
|
|
|
|
const inputId = useId();
|
|
|
|
|
const dropzoneId = useId();
|
|
|
|
|
const listId = useId();
|
|
|
|
|
const labelId = useId();
|
|
|
|
|
|
|
|
|
|
const dir = useDirection(dirProp);
|
|
|
|
|
const propsRef = useAsRef(props);
|
|
|
|
|
const listeners = useLazyRef(() => new Set<() => void>()).current;
|
|
|
|
|
const files = useLazyRef<Map<File, FileState>>(() => new Map()).current;
|
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const isControlled = value !== undefined;
|
|
|
|
|
|
|
|
|
|
const store = useMemo(
|
|
|
|
|
() => createStore(listeners, files, onValueChange, invalid),
|
|
|
|
|
[listeners, files, onValueChange, invalid],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const contextValue = useMemo<FileUploadContextValue>(
|
|
|
|
|
() => ({
|
|
|
|
|
dir,
|
|
|
|
|
disabled,
|
2025-08-29 16:18:32 +02:00
|
|
|
dropzoneId,
|
|
|
|
|
inputId,
|
2025-08-28 18:27:07 +02:00
|
|
|
inputRef,
|
2025-08-29 16:18:32 +02:00
|
|
|
labelId,
|
|
|
|
|
listId,
|
2025-08-28 18:27:07 +02:00
|
|
|
}),
|
|
|
|
|
[dropzoneId, inputId, listId, labelId, dir, disabled],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isControlled) {
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ files: value, variant: "SET_FILES" });
|
2025-08-28 18:27:07 +02:00
|
|
|
} else if (
|
|
|
|
|
defaultValue &&
|
|
|
|
|
defaultValue.length > 0 &&
|
|
|
|
|
!store.getState().files.size
|
|
|
|
|
) {
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ files: defaultValue, variant: "SET_FILES" });
|
2025-08-28 18:27:07 +02:00
|
|
|
}
|
|
|
|
|
}, [value, defaultValue, isControlled, store]);
|
|
|
|
|
|
|
|
|
|
const onFilesChange = useCallback(
|
|
|
|
|
(originalFiles: File[]) => {
|
|
|
|
|
if (propsRef.current.disabled) return;
|
|
|
|
|
|
|
|
|
|
let filesToProcess = [...originalFiles];
|
|
|
|
|
let invalid = false;
|
|
|
|
|
|
|
|
|
|
if (propsRef.current.maxFiles) {
|
|
|
|
|
const currentCount = store.getState().files.size;
|
|
|
|
|
const remainingSlotCount = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
propsRef.current.maxFiles - currentCount,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (remainingSlotCount < filesToProcess.length) {
|
|
|
|
|
const rejectedFiles = filesToProcess.slice(remainingSlotCount);
|
|
|
|
|
invalid = true;
|
|
|
|
|
|
|
|
|
|
filesToProcess = filesToProcess.slice(0, remainingSlotCount);
|
|
|
|
|
|
|
|
|
|
for (const file of rejectedFiles) {
|
|
|
|
|
let rejectionMessage = `Maximum ${propsRef.current.maxFiles} files allowed`;
|
|
|
|
|
|
|
|
|
|
if (propsRef.current.onFileValidate) {
|
|
|
|
|
const validationMessage = propsRef.current.onFileValidate(file);
|
|
|
|
|
if (validationMessage) {
|
|
|
|
|
rejectionMessage = validationMessage;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
propsRef.current.onFileReject?.(file, rejectionMessage);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const acceptedFiles: File[] = [];
|
|
|
|
|
const rejectedFiles: { file: File; message: string }[] = [];
|
|
|
|
|
|
|
|
|
|
for (const file of filesToProcess) {
|
|
|
|
|
let rejected = false;
|
|
|
|
|
let rejectionMessage = "";
|
|
|
|
|
|
|
|
|
|
if (propsRef.current.onFileValidate) {
|
|
|
|
|
const validationMessage = propsRef.current.onFileValidate(file);
|
|
|
|
|
if (validationMessage) {
|
|
|
|
|
rejectionMessage = validationMessage;
|
|
|
|
|
propsRef.current.onFileReject?.(file, rejectionMessage);
|
|
|
|
|
rejected = true;
|
|
|
|
|
invalid = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (propsRef.current.accept) {
|
|
|
|
|
const acceptTypes = propsRef.current.accept
|
|
|
|
|
.split(",")
|
|
|
|
|
.map((t) => t.trim());
|
|
|
|
|
const fileType = file.type;
|
|
|
|
|
const fileExtension = `.${file.name.split(".").pop()}`;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!acceptTypes.some(
|
|
|
|
|
(type) =>
|
|
|
|
|
type === fileType ||
|
|
|
|
|
type === fileExtension ||
|
|
|
|
|
(type.includes("/*") &&
|
|
|
|
|
fileType.startsWith(type.replace("/*", "/"))),
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
rejectionMessage = "File type not accepted";
|
|
|
|
|
propsRef.current.onFileReject?.(file, rejectionMessage);
|
|
|
|
|
rejected = true;
|
|
|
|
|
invalid = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
propsRef.current.maxSize &&
|
|
|
|
|
file.size > propsRef.current.maxSize
|
|
|
|
|
) {
|
|
|
|
|
rejectionMessage = "File too large";
|
|
|
|
|
propsRef.current.onFileReject?.(file, rejectionMessage);
|
|
|
|
|
rejected = true;
|
|
|
|
|
invalid = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!rejected) {
|
|
|
|
|
acceptedFiles.push(file);
|
|
|
|
|
} else {
|
|
|
|
|
rejectedFiles.push({ file, message: rejectionMessage });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (invalid) {
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ invalid, variant: "SET_INVALID" });
|
2025-08-28 18:27:07 +02:00
|
|
|
setTimeout(() => {
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ invalid: false, variant: "SET_INVALID" });
|
2025-08-28 18:27:07 +02:00
|
|
|
}, 2000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (acceptedFiles.length > 0) {
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ files: acceptedFiles, variant: "ADD_FILES" });
|
2025-08-28 18:27:07 +02:00
|
|
|
|
|
|
|
|
if (isControlled && propsRef.current.onValueChange) {
|
|
|
|
|
const currentFiles = Array.from(
|
|
|
|
|
store.getState().files.values(),
|
|
|
|
|
).map((f) => f.file);
|
|
|
|
|
propsRef.current.onValueChange([...currentFiles]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (propsRef.current.onAccept) {
|
|
|
|
|
propsRef.current.onAccept(acceptedFiles);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const file of acceptedFiles) {
|
|
|
|
|
propsRef.current.onFileAccept?.(file);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (propsRef.current.onUpload) {
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
onFilesUpload(acceptedFiles).catch((e) => {
|
|
|
|
|
throw new Error((e as Error).message);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[store, isControlled, propsRef],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onFilesUpload = useCallback(
|
|
|
|
|
async (files: File[]) => {
|
|
|
|
|
try {
|
|
|
|
|
for (const file of files) {
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ file, progress: 0, variant: "SET_PROGRESS" });
|
2025-08-28 18:27:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (propsRef.current.onUpload) {
|
|
|
|
|
await propsRef.current.onUpload(files, {
|
2025-08-29 16:18:32 +02:00
|
|
|
onError: (file, error) => {
|
2025-08-28 18:27:07 +02:00
|
|
|
store.dispatch({
|
2025-08-29 16:18:32 +02:00
|
|
|
error: error.message ?? "Upload failed",
|
2025-08-28 18:27:07 +02:00
|
|
|
file,
|
2025-08-29 16:18:32 +02:00
|
|
|
variant: "SET_ERROR",
|
2025-08-28 18:27:07 +02:00
|
|
|
});
|
|
|
|
|
},
|
2025-08-29 16:18:32 +02:00
|
|
|
onProgress: (file, progress) => {
|
2025-08-28 18:27:07 +02:00
|
|
|
store.dispatch({
|
|
|
|
|
file,
|
2025-08-29 16:18:32 +02:00
|
|
|
progress: Math.min(Math.max(0, progress), 100),
|
|
|
|
|
variant: "SET_PROGRESS",
|
2025-08-28 18:27:07 +02:00
|
|
|
});
|
|
|
|
|
},
|
2025-08-29 16:18:32 +02:00
|
|
|
onSuccess: (file) => {
|
|
|
|
|
store.dispatch({ file, variant: "SET_SUCCESS" });
|
|
|
|
|
},
|
2025-08-28 18:27:07 +02:00
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
for (const file of files) {
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ file, variant: "SET_SUCCESS" });
|
2025-08-28 18:27:07 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMessage =
|
|
|
|
|
error instanceof Error ? error.message : "Upload failed";
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
store.dispatch({
|
|
|
|
|
error: errorMessage,
|
2025-08-29 16:18:32 +02:00
|
|
|
file,
|
|
|
|
|
variant: "SET_ERROR",
|
2025-08-28 18:27:07 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[store, propsRef.current.onUpload],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onInputChange = useCallback(
|
|
|
|
|
(event: ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const files = Array.from(event.target.files ?? []);
|
|
|
|
|
onFilesChange(files);
|
|
|
|
|
event.target.value = "";
|
|
|
|
|
},
|
|
|
|
|
[onFilesChange],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const RootPrimitive = asChild ? Slot : "div";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DirectionContext.Provider value={dir}>
|
|
|
|
|
<StoreContext.Provider value={store}>
|
|
|
|
|
<FileUploadContext.Provider value={contextValue}>
|
|
|
|
|
<RootPrimitive
|
|
|
|
|
data-disabled={disabled ? "" : undefined}
|
|
|
|
|
data-slot="file-upload"
|
|
|
|
|
dir={dir}
|
|
|
|
|
{...rootProps}
|
|
|
|
|
className={cn("relative flex flex-col gap-2", className)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
<input
|
|
|
|
|
accept={accept}
|
2025-08-29 16:18:32 +02:00
|
|
|
aria-describedby={dropzoneId}
|
|
|
|
|
aria-labelledby={labelId}
|
|
|
|
|
className="sr-only"
|
2025-08-28 18:27:07 +02:00
|
|
|
disabled={disabled}
|
2025-08-29 16:18:32 +02:00
|
|
|
id={inputId}
|
2025-08-28 18:27:07 +02:00
|
|
|
multiple={multiple}
|
2025-08-29 16:18:32 +02:00
|
|
|
name={name}
|
2025-08-28 18:27:07 +02:00
|
|
|
onChange={onInputChange}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={inputRef}
|
|
|
|
|
required={required}
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
type="file"
|
2025-08-28 18:27:07 +02:00
|
|
|
/>
|
2025-08-29 16:18:32 +02:00
|
|
|
<span className="sr-only" id={labelId}>
|
2025-08-28 18:27:07 +02:00
|
|
|
{label ?? "File upload"}
|
|
|
|
|
</span>
|
|
|
|
|
</RootPrimitive>
|
|
|
|
|
</FileUploadContext.Provider>
|
|
|
|
|
</StoreContext.Provider>
|
|
|
|
|
</DirectionContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
},
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
FileUploadRoot.displayName = ROOT_NAME;
|
|
|
|
|
|
|
|
|
|
interface FileUploadDropzoneProps extends ComponentPropsWithoutRef<"div"> {
|
2025-08-28 18:27:07 +02:00
|
|
|
asChild?: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadDropzone = forwardRef<HTMLDivElement, FileUploadDropzoneProps>(
|
2025-08-28 18:27:07 +02:00
|
|
|
(props, forwardedRef) => {
|
|
|
|
|
const { asChild, className, ...dropzoneProps } = props;
|
|
|
|
|
|
|
|
|
|
const context = useFileUploadContext(DROPZONE_NAME);
|
|
|
|
|
const store = useStoreContext(DROPZONE_NAME);
|
|
|
|
|
const dragOver = useStore((state) => state.dragOver);
|
|
|
|
|
const invalid = useStore((state) => state.invalid);
|
|
|
|
|
const propsRef = useAsRef(dropzoneProps);
|
|
|
|
|
|
|
|
|
|
const onClick = useCallback(
|
|
|
|
|
(event: MouseEvent<HTMLDivElement>) => {
|
|
|
|
|
propsRef.current?.onClick?.(event);
|
|
|
|
|
|
|
|
|
|
if (event.defaultPrevented) return;
|
|
|
|
|
|
|
|
|
|
const target = event.target;
|
|
|
|
|
|
|
|
|
|
const isFromTrigger =
|
|
|
|
|
target instanceof HTMLElement &&
|
|
|
|
|
target.closest('[data-slot="file-upload-trigger"]');
|
|
|
|
|
|
|
|
|
|
if (!isFromTrigger) {
|
|
|
|
|
context.inputRef.current?.click();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[context.inputRef, propsRef],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onDragOver = useCallback(
|
|
|
|
|
(event: DragEvent<HTMLDivElement>) => {
|
|
|
|
|
propsRef.current?.onDragOver?.(event);
|
|
|
|
|
|
|
|
|
|
if (event.defaultPrevented) return;
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ dragOver: true, variant: "SET_DRAG_OVER" });
|
2025-08-28 18:27:07 +02:00
|
|
|
},
|
|
|
|
|
[store, propsRef.current.onDragOver],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onDragEnter = useCallback(
|
|
|
|
|
(event: DragEvent<HTMLDivElement>) => {
|
|
|
|
|
propsRef.current?.onDragEnter?.(event);
|
|
|
|
|
|
|
|
|
|
if (event.defaultPrevented) return;
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ dragOver: true, variant: "SET_DRAG_OVER" });
|
2025-08-28 18:27:07 +02:00
|
|
|
},
|
|
|
|
|
[store, propsRef.current.onDragEnter],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onDragLeave = useCallback(
|
|
|
|
|
(event: DragEvent<HTMLDivElement>) => {
|
|
|
|
|
propsRef.current?.onDragLeave?.(event);
|
|
|
|
|
|
|
|
|
|
if (event.defaultPrevented) return;
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ dragOver: false, variant: "SET_DRAG_OVER" });
|
2025-08-28 18:27:07 +02:00
|
|
|
},
|
|
|
|
|
[store, propsRef.current.onDragLeave],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onDrop = useCallback(
|
|
|
|
|
(event: DragEvent<HTMLDivElement>) => {
|
|
|
|
|
propsRef.current?.onDrop?.(event);
|
|
|
|
|
|
|
|
|
|
if (event.defaultPrevented) return;
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
2025-08-29 16:18:32 +02:00
|
|
|
store.dispatch({ dragOver: false, variant: "SET_DRAG_OVER" });
|
2025-08-28 18:27:07 +02:00
|
|
|
|
|
|
|
|
const files = Array.from(event.dataTransfer.files);
|
|
|
|
|
const inputElement = context.inputRef.current;
|
|
|
|
|
if (!inputElement) return;
|
|
|
|
|
|
|
|
|
|
const dataTransfer = new DataTransfer();
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
dataTransfer.items.add(file);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inputElement.files = dataTransfer.files;
|
|
|
|
|
inputElement.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
|
},
|
|
|
|
|
[store, context.inputRef, propsRef.current.onDrop],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onKeyDown = useCallback(
|
|
|
|
|
(event: KeyboardEvent<HTMLDivElement>) => {
|
|
|
|
|
propsRef.current?.onKeyDown?.(event);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!event.defaultPrevented &&
|
|
|
|
|
(event.key === "Enter" || event.key === " ")
|
|
|
|
|
) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
context.inputRef.current?.click();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[context.inputRef, propsRef.current.onKeyDown],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const DropzonePrimitive = asChild ? Slot : "div";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DropzonePrimitive
|
|
|
|
|
aria-controls={`${context.inputId} ${context.listId}`}
|
|
|
|
|
data-disabled={context.disabled ? "" : undefined}
|
|
|
|
|
data-dragging={dragOver ? "" : undefined}
|
2025-08-29 16:18:32 +02:00
|
|
|
//aria-disabled={context.disabled}
|
|
|
|
|
//aria-invalid={invalid}
|
2025-08-28 18:27:07 +02:00
|
|
|
data-invalid={invalid ? "" : undefined}
|
|
|
|
|
data-slot="file-upload-dropzone"
|
|
|
|
|
dir={context.dir}
|
2025-08-29 16:18:32 +02:00
|
|
|
id={context.dropzoneId}
|
|
|
|
|
role="region"
|
2025-08-28 18:27:07 +02:00
|
|
|
{...dropzoneProps}
|
|
|
|
|
className={cn(
|
|
|
|
|
"data-[dragging]:border-primary data-[invalid]:border-destructive data-[invalid]:ring-destructive/20 hover:bg-accent/30 focus-visible:border-ring/50 relative flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 outline-hidden transition-colors select-none data-[disabled]:pointer-events-none",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
onDragEnter={onDragEnter}
|
|
|
|
|
onDragLeave={onDragLeave}
|
|
|
|
|
onDragOver={onDragOver}
|
|
|
|
|
onDrop={onDrop}
|
|
|
|
|
onKeyDown={onKeyDown}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
|
|
|
|
tabIndex={context.disabled ? undefined : 0}
|
2025-08-28 18:27:07 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
},
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
FileUploadDropzone.displayName = DROPZONE_NAME;
|
|
|
|
|
|
|
|
|
|
interface FileUploadTriggerProps extends ComponentPropsWithoutRef<"button"> {
|
2025-08-28 18:27:07 +02:00
|
|
|
asChild?: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadTrigger = forwardRef<HTMLButtonElement, FileUploadTriggerProps>(
|
2025-08-28 18:27:07 +02:00
|
|
|
(props, forwardedRef) => {
|
|
|
|
|
const { asChild, ...triggerProps } = props;
|
|
|
|
|
const context = useFileUploadContext(TRIGGER_NAME);
|
|
|
|
|
const propsRef = useAsRef(triggerProps);
|
|
|
|
|
|
|
|
|
|
const onClick = useCallback(
|
|
|
|
|
(event: MouseEvent<HTMLButtonElement>) => {
|
|
|
|
|
propsRef.current?.onClick?.(event);
|
|
|
|
|
|
|
|
|
|
if (event.defaultPrevented) return;
|
|
|
|
|
|
|
|
|
|
context.inputRef.current?.click();
|
|
|
|
|
},
|
|
|
|
|
[context.inputRef, propsRef.current],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const TriggerPrimitive = asChild ? Slot : "button";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<TriggerPrimitive
|
|
|
|
|
aria-controls={context.inputId}
|
|
|
|
|
data-disabled={context.disabled ? "" : undefined}
|
|
|
|
|
data-slot="file-upload-trigger"
|
2025-08-29 16:18:32 +02:00
|
|
|
type="button"
|
2025-08-28 18:27:07 +02:00
|
|
|
{...triggerProps}
|
|
|
|
|
disabled={context.disabled}
|
|
|
|
|
onClick={onClick}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
2025-08-28 18:27:07 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
},
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
FileUploadTrigger.displayName = TRIGGER_NAME;
|
|
|
|
|
|
|
|
|
|
interface FileUploadListProps extends ComponentPropsWithoutRef<"div"> {
|
2025-08-28 18:27:07 +02:00
|
|
|
orientation?: "horizontal" | "vertical";
|
|
|
|
|
asChild?: boolean;
|
|
|
|
|
forceMount?: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadList = forwardRef<HTMLDivElement, FileUploadListProps>(
|
2025-08-28 18:27:07 +02:00
|
|
|
(props, forwardedRef) => {
|
|
|
|
|
const {
|
|
|
|
|
className,
|
|
|
|
|
orientation = "vertical",
|
|
|
|
|
asChild,
|
|
|
|
|
forceMount,
|
|
|
|
|
...listProps
|
|
|
|
|
} = props;
|
|
|
|
|
|
|
|
|
|
const context = useFileUploadContext(LIST_NAME);
|
|
|
|
|
|
|
|
|
|
const shouldRender =
|
|
|
|
|
forceMount || useStore((state) => state.files.size > 0);
|
|
|
|
|
|
|
|
|
|
if (!shouldRender) return null;
|
|
|
|
|
|
|
|
|
|
const ListPrimitive = asChild ? Slot : "div";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ListPrimitive
|
|
|
|
|
data-orientation={orientation}
|
|
|
|
|
data-slot="file-upload-list"
|
2025-08-29 16:18:32 +02:00
|
|
|
//aria-orientation={orientation}
|
2025-08-28 18:27:07 +02:00
|
|
|
data-state={shouldRender ? "active" : "inactive"}
|
|
|
|
|
dir={context.dir}
|
2025-08-29 16:18:32 +02:00
|
|
|
id={context.listId}
|
|
|
|
|
role="list"
|
2025-08-28 18:27:07 +02:00
|
|
|
{...listProps}
|
|
|
|
|
className={cn(
|
|
|
|
|
"data-[state=active]:animate-in data-[state=inactive]:animate-out data-[state=active]:fade-in-0 data-[state=inactive]:fade-out-0 data-[state=active]:slide-in-from-top-2 data-[state=inactive]:slide-out-to-top-2 flex flex-col gap-2",
|
|
|
|
|
orientation === "horizontal" && "flex-row overflow-x-auto p-1.5",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
2025-08-28 18:27:07 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
},
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
FileUploadList.displayName = LIST_NAME;
|
|
|
|
|
|
|
|
|
|
interface FileUploadItemContextValue {
|
2025-08-28 18:27:07 +02:00
|
|
|
id: string;
|
|
|
|
|
fileState: FileState | undefined;
|
|
|
|
|
nameId: string;
|
|
|
|
|
sizeId: string;
|
|
|
|
|
statusId: string;
|
|
|
|
|
messageId: string;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadItemContext = createContext<FileUploadItemContextValue | null>(
|
2025-08-28 18:27:07 +02:00
|
|
|
null,
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
function useFileUploadItemContext(name: keyof typeof FILE_UPLOAD_ERRORS) {
|
2025-08-28 18:27:07 +02:00
|
|
|
const context = useContext(FileUploadItemContext);
|
|
|
|
|
if (!context) {
|
|
|
|
|
throw new Error(FILE_UPLOAD_ERRORS[name]);
|
|
|
|
|
}
|
|
|
|
|
return context;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FileUploadItemProps extends ComponentPropsWithoutRef<"div"> {
|
2025-08-28 18:27:07 +02:00
|
|
|
value: File;
|
|
|
|
|
asChild?: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadItem = forwardRef<HTMLDivElement, FileUploadItemProps>(
|
2025-08-28 18:27:07 +02:00
|
|
|
(props, forwardedRef) => {
|
|
|
|
|
const { value, asChild, className, ...itemProps } = props;
|
|
|
|
|
|
|
|
|
|
const id = useId();
|
|
|
|
|
const statusId = `${id}-status`;
|
|
|
|
|
const nameId = `${id}-name`;
|
|
|
|
|
const sizeId = `${id}-size`;
|
|
|
|
|
const messageId = `${id}-message`;
|
|
|
|
|
|
|
|
|
|
const context = useFileUploadContext(ITEM_NAME);
|
|
|
|
|
const fileState = useStore((state) => state.files.get(value));
|
|
|
|
|
const fileCount = useStore((state) => state.files.size);
|
|
|
|
|
const fileIndex = useStore((state) => {
|
|
|
|
|
const files = Array.from(state.files.keys());
|
|
|
|
|
return files.indexOf(value) + 1;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const itemContext = useMemo(
|
|
|
|
|
() => ({
|
|
|
|
|
fileState,
|
2025-08-29 16:18:32 +02:00
|
|
|
id,
|
|
|
|
|
messageId,
|
2025-08-28 18:27:07 +02:00
|
|
|
nameId,
|
|
|
|
|
sizeId,
|
|
|
|
|
statusId,
|
|
|
|
|
}),
|
|
|
|
|
[id, fileState, statusId, nameId, sizeId, messageId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!fileState) return null;
|
|
|
|
|
|
2025-09-01 16:48:03 +02:00
|
|
|
const statusText = (() => {
|
|
|
|
|
if (fileState.error) return `Error: ${fileState.error}`;
|
|
|
|
|
if (fileState.status === "uploading")
|
|
|
|
|
return `Uploading: ${fileState.progress}% complete`;
|
|
|
|
|
if (fileState.status === "success") return "Upload complete";
|
|
|
|
|
return "Ready to upload";
|
|
|
|
|
})();
|
2025-08-28 18:27:07 +02:00
|
|
|
|
|
|
|
|
const ItemPrimitive = asChild ? Slot : "div";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<FileUploadItemContext.Provider value={itemContext}>
|
|
|
|
|
<ItemPrimitive
|
|
|
|
|
aria-describedby={`${nameId} ${sizeId} ${statusId} ${
|
|
|
|
|
fileState.error ? messageId : ""
|
|
|
|
|
}`}
|
|
|
|
|
aria-labelledby={nameId}
|
2025-08-29 16:18:32 +02:00
|
|
|
aria-posinset={fileIndex}
|
|
|
|
|
aria-setsize={fileCount}
|
2025-08-28 18:27:07 +02:00
|
|
|
data-slot="file-upload-item"
|
|
|
|
|
dir={context.dir}
|
2025-08-29 16:18:32 +02:00
|
|
|
id={id}
|
|
|
|
|
role="listitem"
|
2025-08-28 18:27:07 +02:00
|
|
|
{...itemProps}
|
|
|
|
|
className={cn(
|
|
|
|
|
"relative flex items-center gap-2.5 rounded-md border p-3 has-[_[data-slot=file-upload-progress]]:flex-col has-[_[data-slot=file-upload-progress]]:items-start",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
{props.children}
|
2025-08-29 16:18:32 +02:00
|
|
|
<span className="sr-only" id={statusId}>
|
2025-08-28 18:27:07 +02:00
|
|
|
{statusText}
|
|
|
|
|
</span>
|
|
|
|
|
</ItemPrimitive>
|
|
|
|
|
</FileUploadItemContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
},
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
FileUploadItem.displayName = ITEM_NAME;
|
|
|
|
|
|
|
|
|
|
function formatBytes(bytes: number) {
|
2025-08-28 18:27:07 +02:00
|
|
|
if (bytes === 0) return "0 B";
|
|
|
|
|
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
|
|
|
return `${(bytes / 1024 ** i).toFixed(i ? 1 : 0)} ${sizes[i]}`;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getFileIcon(file: File) {
|
2025-08-28 18:27:07 +02:00
|
|
|
const type = file.type;
|
|
|
|
|
const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
|
|
|
|
|
|
|
|
|
|
if (type.startsWith("video/")) {
|
|
|
|
|
return <FileVideoIcon />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (type.startsWith("audio/")) {
|
|
|
|
|
return <FileAudioIcon />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
type.startsWith("text/") ||
|
|
|
|
|
["txt", "md", "rtf", "pdf"].includes(extension)
|
|
|
|
|
) {
|
|
|
|
|
return <FileTextIcon />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
[
|
|
|
|
|
"html",
|
|
|
|
|
"css",
|
|
|
|
|
"js",
|
|
|
|
|
"jsx",
|
|
|
|
|
"ts",
|
|
|
|
|
"tsx",
|
|
|
|
|
"json",
|
|
|
|
|
"xml",
|
|
|
|
|
"php",
|
|
|
|
|
"py",
|
|
|
|
|
"rb",
|
|
|
|
|
"java",
|
|
|
|
|
"c",
|
|
|
|
|
"cpp",
|
|
|
|
|
"cs",
|
|
|
|
|
].includes(extension)
|
|
|
|
|
) {
|
|
|
|
|
return <FileCodeIcon />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (["zip", "rar", "7z", "tar", "gz", "bz2"].includes(extension)) {
|
|
|
|
|
return <FileArchiveIcon />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
["exe", "msi", "app", "apk", "deb", "rpm"].includes(extension) ||
|
|
|
|
|
type.startsWith("application/")
|
|
|
|
|
) {
|
|
|
|
|
return <FileCogIcon />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <FileIcon />;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FileUploadItemPreviewProps extends ComponentPropsWithoutRef<"div"> {
|
2025-08-28 18:27:07 +02:00
|
|
|
render?: (file: File) => ReactNode;
|
|
|
|
|
asChild?: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadItemPreview = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
HTMLDivElement,
|
|
|
|
|
FileUploadItemPreviewProps
|
2025-08-04 17:45:44 +02:00
|
|
|
>((props, forwardedRef) => {
|
2025-08-28 18:27:07 +02:00
|
|
|
const { render, asChild, children, className, ...previewProps } = props;
|
|
|
|
|
|
|
|
|
|
const itemContext = useFileUploadItemContext(ITEM_PREVIEW_NAME);
|
|
|
|
|
|
|
|
|
|
const isImage = itemContext.fileState?.file.type.startsWith("image/");
|
|
|
|
|
|
|
|
|
|
const onPreviewRender = useCallback(
|
|
|
|
|
(file: File) => {
|
|
|
|
|
if (render) return render(file);
|
|
|
|
|
|
|
|
|
|
if (isImage) {
|
|
|
|
|
return (
|
|
|
|
|
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: <intended>
|
|
|
|
|
<img
|
|
|
|
|
alt={file.name}
|
|
|
|
|
className="size-full rounded-sm object-cover"
|
|
|
|
|
onLoad={(event) => {
|
|
|
|
|
if (!(event.target instanceof HTMLImageElement)) return;
|
|
|
|
|
URL.revokeObjectURL(event.target.src);
|
|
|
|
|
}}
|
2025-08-29 16:18:32 +02:00
|
|
|
src={URL.createObjectURL(file)}
|
2025-08-28 18:27:07 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return getFileIcon(file);
|
|
|
|
|
},
|
|
|
|
|
[isImage, render],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!itemContext.fileState) return null;
|
|
|
|
|
|
|
|
|
|
const ItemPreviewPrimitive = asChild ? Slot : "div";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ItemPreviewPrimitive
|
|
|
|
|
aria-labelledby={itemContext.nameId}
|
|
|
|
|
data-slot="file-upload-preview"
|
|
|
|
|
{...previewProps}
|
|
|
|
|
className={cn(
|
|
|
|
|
"relative flex size-10 shrink-0 items-center justify-center rounded-md",
|
|
|
|
|
isImage ? "object-cover" : "bg-accent/50 [&>svg]:size-7",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
{onPreviewRender(itemContext.fileState.file)}
|
|
|
|
|
{children}
|
|
|
|
|
</ItemPreviewPrimitive>
|
|
|
|
|
);
|
2025-08-04 17:45:44 +02:00
|
|
|
});
|
|
|
|
|
FileUploadItemPreview.displayName = ITEM_PREVIEW_NAME;
|
|
|
|
|
|
|
|
|
|
interface FileUploadItemMetadataProps extends ComponentPropsWithoutRef<"div"> {
|
2025-08-28 18:27:07 +02:00
|
|
|
asChild?: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadItemMetadata = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
HTMLDivElement,
|
|
|
|
|
FileUploadItemMetadataProps
|
2025-08-04 17:45:44 +02:00
|
|
|
>((props, forwardedRef) => {
|
2025-08-28 18:27:07 +02:00
|
|
|
const { asChild, children, className, ...metadataProps } = props;
|
|
|
|
|
|
|
|
|
|
const context = useFileUploadContext(ITEM_METADATA_NAME);
|
|
|
|
|
const itemContext = useFileUploadItemContext(ITEM_METADATA_NAME);
|
|
|
|
|
|
|
|
|
|
if (!itemContext.fileState) return null;
|
|
|
|
|
|
|
|
|
|
const ItemMetadataPrimitive = asChild ? Slot : "div";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ItemMetadataPrimitive
|
|
|
|
|
data-slot="file-upload-metadata"
|
|
|
|
|
dir={context.dir}
|
|
|
|
|
{...metadataProps}
|
|
|
|
|
className={cn("flex min-w-0 flex-1 flex-col", className)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
{children ?? (
|
|
|
|
|
<>
|
|
|
|
|
<span
|
|
|
|
|
className="truncate text-sm font-medium"
|
2025-08-29 16:18:32 +02:00
|
|
|
id={itemContext.nameId}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
{itemContext.fileState.file.name}
|
|
|
|
|
</span>
|
|
|
|
|
<span
|
|
|
|
|
className="text-muted-foreground text-xs"
|
2025-08-29 16:18:32 +02:00
|
|
|
id={itemContext.sizeId}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
{formatBytes(itemContext.fileState.file.size)}
|
|
|
|
|
</span>
|
|
|
|
|
{itemContext.fileState.error && (
|
|
|
|
|
<span
|
|
|
|
|
className="text-destructive text-xs"
|
2025-08-29 16:18:32 +02:00
|
|
|
id={itemContext.messageId}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
{itemContext.fileState.error}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</ItemMetadataPrimitive>
|
|
|
|
|
);
|
2025-08-04 17:45:44 +02:00
|
|
|
});
|
|
|
|
|
FileUploadItemMetadata.displayName = ITEM_METADATA_NAME;
|
|
|
|
|
|
|
|
|
|
interface FileUploadItemProgressProps extends ComponentPropsWithoutRef<"div"> {
|
2025-08-28 18:27:07 +02:00
|
|
|
asChild?: boolean;
|
|
|
|
|
circular?: boolean;
|
|
|
|
|
size?: number;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadItemProgress = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
HTMLDivElement,
|
|
|
|
|
FileUploadItemProgressProps
|
2025-08-04 17:45:44 +02:00
|
|
|
>((props, forwardedRef) => {
|
2025-08-28 18:27:07 +02:00
|
|
|
const { circular, size = 40, asChild, className, ...progressProps } = props;
|
|
|
|
|
|
|
|
|
|
const itemContext = useFileUploadItemContext(ITEM_PROGRESS_NAME);
|
|
|
|
|
|
|
|
|
|
if (!itemContext.fileState) return null;
|
|
|
|
|
|
|
|
|
|
const ItemProgressPrimitive = asChild ? Slot : "div";
|
|
|
|
|
|
|
|
|
|
if (circular) {
|
|
|
|
|
if (itemContext.fileState.status === "success") return null;
|
|
|
|
|
|
|
|
|
|
const circumference = 2 * Math.PI * ((size - 4) / 2);
|
|
|
|
|
const strokeDashoffset =
|
|
|
|
|
circumference - (itemContext.fileState.progress / 100) * circumference;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ItemProgressPrimitive
|
2025-08-29 16:18:32 +02:00
|
|
|
aria-labelledby={itemContext.nameId}
|
2025-08-28 18:27:07 +02:00
|
|
|
aria-valuemax={100}
|
2025-08-29 16:18:32 +02:00
|
|
|
aria-valuemin={0}
|
2025-08-28 18:27:07 +02:00
|
|
|
aria-valuenow={itemContext.fileState.progress}
|
|
|
|
|
aria-valuetext={`${itemContext.fileState.progress}%`}
|
|
|
|
|
data-slot="file-upload-progress"
|
2025-08-29 16:18:32 +02:00
|
|
|
role="progressbar"
|
2025-08-28 18:27:07 +02:00
|
|
|
{...progressProps}
|
|
|
|
|
className={cn(
|
|
|
|
|
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
<svg
|
|
|
|
|
className="rotate-[-90deg] transform"
|
|
|
|
|
fill="none"
|
2025-08-29 16:18:32 +02:00
|
|
|
height={size}
|
2025-08-28 18:27:07 +02:00
|
|
|
stroke="currentColor"
|
2025-08-29 16:18:32 +02:00
|
|
|
viewBox={`0 0 ${size} ${size}`}
|
|
|
|
|
width={size}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
<title>Progress</title>
|
|
|
|
|
<circle
|
|
|
|
|
className="text-primary/20"
|
|
|
|
|
cx={size / 2}
|
|
|
|
|
cy={size / 2}
|
|
|
|
|
r={(size - 4) / 2}
|
2025-08-29 16:18:32 +02:00
|
|
|
strokeWidth="2"
|
2025-08-28 18:27:07 +02:00
|
|
|
/>
|
|
|
|
|
<circle
|
|
|
|
|
className="text-primary transition-all"
|
|
|
|
|
cx={size / 2}
|
|
|
|
|
cy={size / 2}
|
|
|
|
|
r={(size - 4) / 2}
|
2025-08-29 16:18:32 +02:00
|
|
|
strokeDasharray={circumference}
|
|
|
|
|
strokeDashoffset={strokeDashoffset}
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeWidth="2"
|
2025-08-28 18:27:07 +02:00
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
</ItemProgressPrimitive>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ItemProgressPrimitive
|
2025-08-29 16:18:32 +02:00
|
|
|
aria-labelledby={itemContext.nameId}
|
2025-08-28 18:27:07 +02:00
|
|
|
aria-valuemax={100}
|
2025-08-29 16:18:32 +02:00
|
|
|
aria-valuemin={0}
|
2025-08-28 18:27:07 +02:00
|
|
|
aria-valuenow={itemContext.fileState.progress}
|
|
|
|
|
aria-valuetext={`${itemContext.fileState.progress}%`}
|
|
|
|
|
data-slot="file-upload-progress"
|
2025-08-29 16:18:32 +02:00
|
|
|
role="progressbar"
|
2025-08-28 18:27:07 +02:00
|
|
|
{...progressProps}
|
|
|
|
|
className={cn(
|
|
|
|
|
"bg-primary/20 relative h-1.5 w-full overflow-hidden rounded-full",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
2025-08-28 18:27:07 +02:00
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="bg-primary h-full w-full flex-1 transition-all"
|
|
|
|
|
style={{
|
|
|
|
|
transform: `translateX(-${100 - itemContext.fileState.progress}%)`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</ItemProgressPrimitive>
|
|
|
|
|
);
|
2025-08-04 17:45:44 +02:00
|
|
|
});
|
|
|
|
|
FileUploadItemProgress.displayName = ITEM_PROGRESS_NAME;
|
|
|
|
|
|
|
|
|
|
interface FileUploadItemDeleteProps extends ComponentPropsWithoutRef<"button"> {
|
2025-08-28 18:27:07 +02:00
|
|
|
asChild?: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadItemDelete = forwardRef<
|
2025-08-28 18:27:07 +02:00
|
|
|
HTMLButtonElement,
|
|
|
|
|
FileUploadItemDeleteProps
|
2025-08-04 17:45:44 +02:00
|
|
|
>((props, forwardedRef) => {
|
2025-08-28 18:27:07 +02:00
|
|
|
const { asChild, ...deleteProps } = props;
|
|
|
|
|
|
|
|
|
|
const store = useStoreContext(ITEM_DELETE_NAME);
|
|
|
|
|
const itemContext = useFileUploadItemContext(ITEM_DELETE_NAME);
|
|
|
|
|
const propsRef = useAsRef(deleteProps);
|
|
|
|
|
|
|
|
|
|
const onClick = useCallback(
|
|
|
|
|
(event: MouseEvent<HTMLButtonElement>) => {
|
|
|
|
|
propsRef.current?.onClick?.(event);
|
|
|
|
|
|
|
|
|
|
if (!itemContext.fileState || event.defaultPrevented) return;
|
|
|
|
|
|
|
|
|
|
store.dispatch({
|
|
|
|
|
file: itemContext.fileState.file,
|
2025-08-29 16:18:32 +02:00
|
|
|
variant: "REMOVE_FILE",
|
2025-08-28 18:27:07 +02:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[store, itemContext.fileState, propsRef.current?.onClick],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!itemContext.fileState) return null;
|
|
|
|
|
|
|
|
|
|
const ItemDeletePrimitive = asChild ? Slot : "button";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ItemDeletePrimitive
|
|
|
|
|
aria-controls={itemContext.id}
|
|
|
|
|
aria-describedby={itemContext.nameId}
|
|
|
|
|
data-slot="file-upload-item-delete"
|
2025-08-29 16:18:32 +02:00
|
|
|
type="button"
|
2025-08-28 18:27:07 +02:00
|
|
|
{...deleteProps}
|
|
|
|
|
onClick={onClick}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
2025-08-28 18:27:07 +02:00
|
|
|
/>
|
|
|
|
|
);
|
2025-08-04 17:45:44 +02:00
|
|
|
});
|
|
|
|
|
FileUploadItemDelete.displayName = ITEM_DELETE_NAME;
|
|
|
|
|
|
|
|
|
|
interface FileUploadClearProps extends ComponentPropsWithoutRef<"button"> {
|
2025-08-28 18:27:07 +02:00
|
|
|
forceMount?: boolean;
|
|
|
|
|
asChild?: boolean;
|
2025-08-04 17:45:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileUploadClear = forwardRef<HTMLButtonElement, FileUploadClearProps>(
|
2025-08-28 18:27:07 +02:00
|
|
|
(props, forwardedRef) => {
|
|
|
|
|
const { asChild, forceMount, disabled, ...clearProps } = props;
|
|
|
|
|
|
|
|
|
|
const context = useFileUploadContext(CLEAR_NAME);
|
|
|
|
|
const store = useStoreContext(CLEAR_NAME);
|
|
|
|
|
const propsRef = useAsRef(clearProps);
|
|
|
|
|
|
|
|
|
|
const isDisabled = disabled || context.disabled;
|
|
|
|
|
|
|
|
|
|
const onClick = useCallback(
|
|
|
|
|
(event: MouseEvent<HTMLButtonElement>) => {
|
|
|
|
|
propsRef.current?.onClick?.(event);
|
|
|
|
|
|
|
|
|
|
if (event.defaultPrevented) return;
|
|
|
|
|
|
|
|
|
|
store.dispatch({ variant: "CLEAR" });
|
|
|
|
|
},
|
|
|
|
|
[store, propsRef],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const shouldRender =
|
|
|
|
|
forceMount || useStore((state) => state.files.size > 0);
|
|
|
|
|
|
|
|
|
|
if (!shouldRender) return null;
|
|
|
|
|
|
|
|
|
|
const ClearPrimitive = asChild ? Slot : "button";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ClearPrimitive
|
|
|
|
|
aria-controls={context.listId}
|
|
|
|
|
data-disabled={isDisabled ? "" : undefined}
|
2025-08-29 16:18:32 +02:00
|
|
|
data-slot="file-upload-clear"
|
|
|
|
|
type="button"
|
2025-08-28 18:27:07 +02:00
|
|
|
{...clearProps}
|
|
|
|
|
disabled={isDisabled}
|
|
|
|
|
onClick={onClick}
|
2025-08-29 16:18:32 +02:00
|
|
|
ref={forwardedRef}
|
2025-08-28 18:27:07 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
},
|
2025-08-04 17:45:44 +02:00
|
|
|
);
|
|
|
|
|
FileUploadClear.displayName = CLEAR_NAME;
|
|
|
|
|
|
|
|
|
|
const FileUpload = FileUploadRoot;
|
|
|
|
|
// const Root = FileUploadRoot;
|
|
|
|
|
// const Trigger = FileUploadTrigger;
|
|
|
|
|
// const Dropzone = FileUploadDropzone;
|
|
|
|
|
// const List = FileUploadList;
|
|
|
|
|
// const Item = FileUploadItem;
|
|
|
|
|
// const ItemPreview = FileUploadItemPreview;
|
|
|
|
|
// const ItemMetadata = FileUploadItemMetadata;
|
|
|
|
|
// const ItemProgress = FileUploadItemProgress;
|
|
|
|
|
// const ItemDelete = FileUploadItemDelete;
|
|
|
|
|
// const Clear = FileUploadClear;
|
|
|
|
|
|
|
|
|
|
export {
|
2025-08-28 18:27:07 +02:00
|
|
|
FileUpload,
|
|
|
|
|
FileUploadDropzone,
|
|
|
|
|
FileUploadTrigger,
|
|
|
|
|
FileUploadList,
|
|
|
|
|
FileUploadItem,
|
|
|
|
|
FileUploadItemPreview,
|
|
|
|
|
FileUploadItemMetadata,
|
|
|
|
|
FileUploadItemProgress,
|
|
|
|
|
FileUploadItemDelete,
|
|
|
|
|
//FileUploadClear,
|
|
|
|
|
/*
|
2025-08-04 17:45:44 +02:00
|
|
|
Root,
|
|
|
|
|
Dropzone,
|
|
|
|
|
Trigger,
|
|
|
|
|
List,
|
|
|
|
|
Item,
|
|
|
|
|
ItemPreview,
|
|
|
|
|
ItemMetadata,
|
|
|
|
|
ItemProgress,
|
|
|
|
|
ItemDelete,
|
|
|
|
|
Clear,
|
|
|
|
|
|
|
|
|
|
useStore as useFileUpload,*/
|
|
|
|
|
};
|