@carbon/react
Version:
React components for the Carbon Design System
279 lines (277 loc) • 9.32 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import { usePrefix } from "../../internal/usePrefix.js";
import { Text } from "../Text/Text.js";
import { Enter, Space } from "../../internal/keyboard/keys.js";
import { matches } from "../../internal/keyboard/match.js";
import { useId } from "../../internal/useId.js";
import { useFeatureFlag } from "../FeatureFlags/index.js";
import { ButtonKinds } from "../Button/Button.js";
import Filename from "./Filename.js";
import FileUploaderButton from "./FileUploaderButton.js";
import classNames from "classnames";
import React, { forwardRef, useCallback, useImperativeHandle, useState } from "react";
import PropTypes from "prop-types";
import { jsx, jsxs } from "react/jsx-runtime";
//#region src/components/FileUploader/FileUploader.tsx
/**
* Copyright IBM Corp. 2016, 2026
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
const FileUploader = forwardRef(({ accept, buttonKind, buttonLabel, className, disabled, filenameStatus, iconDescription, labelDescription, labelTitle, maxFileSize, multiple, name, onAddFiles, onChange, onClick, onDelete, size, ...other }, ref) => {
const fileUploaderInstanceId = useId("file-uploader");
const prefix = usePrefix();
const enhancedFileUploaderEnabled = useFeatureFlag("enable-enhanced-file-uploader");
const [fileItems, setFileItems] = useState([]);
const [legacyFileNames, setLegacyFileNames] = useState([]);
const uploaderButton = React.createRef();
const nodes = [];
const createFileItem = useCallback((file) => ({
name: file.name,
uuid: `${fileUploaderInstanceId}-${Date.now()}-${Array.from(crypto.getRandomValues(new Uint8Array(8))).map((b) => b.toString(36)).join("")}`,
file
}), [fileUploaderInstanceId]);
/**
* Validates files based on file size restrictions.
* Marks invalid files with `invalidFileType: true` but includes them in the result.
*
* Note: The `accept` prop is passed to the native HTML input element (`FileUploaderButton`),
* which provides UI-level filtering in the file picker dialog, but there is no JavaScript validation
* for file types - users can bypass this by changing the file type filter in the dialog.
* https://github.com/carbon-design-system/carbon/issues/21166
*/
const validateFiles = useCallback((files) => {
return files.map((file) => {
if (maxFileSize && file.size > maxFileSize) file.invalidFileType = true;
return file;
});
}, [maxFileSize]);
const handleChange = useCallback((evt) => {
evt.stopPropagation();
const incomingFiles = Array.from(evt.target.files);
const validatedFiles = validateFiles(multiple ? incomingFiles : [incomingFiles[0]]);
if (onAddFiles) onAddFiles(evt, { addedFiles: validatedFiles });
const validFiles = validatedFiles.filter((file) => !file.invalidFileType);
if (validFiles.length === 0) return;
if (enhancedFileUploaderEnabled) {
const newFileItems = validFiles.map(createFileItem);
let updatedFileItems;
if (multiple) {
const existingNames = new Set(fileItems.map((item) => item.name));
const uniqueNewItems = newFileItems.filter((item) => !existingNames.has(item.name));
updatedFileItems = [...fileItems, ...uniqueNewItems];
} else updatedFileItems = newFileItems;
setFileItems(updatedFileItems);
if (onChange) {
const allFiles = updatedFileItems.map((item) => item.file);
onChange({
...evt,
target: {
...evt.target,
files: Object.assign(allFiles, { item: (index) => allFiles[index] || null }),
addedFiles: newFileItems,
currentFiles: updatedFileItems,
action: "add"
}
});
}
} else {
const filenames = validFiles.map((file) => file.name);
setLegacyFileNames(multiple ? [...new Set([...legacyFileNames, ...filenames])] : filenames);
if (onChange) onChange(evt);
}
}, [
enhancedFileUploaderEnabled,
fileItems,
legacyFileNames,
multiple,
onAddFiles,
onChange,
createFileItem,
validateFiles
]);
const handleClick = useCallback((evt, { index, filenameStatus }) => {
if (filenameStatus === "edit") {
evt.stopPropagation();
if (enhancedFileUploaderEnabled) {
const deletedItem = fileItems[index];
if (!deletedItem) return;
const remainingItems = fileItems.filter((_, i) => i !== index);
setFileItems(remainingItems);
const remainingFiles = remainingItems.map((item) => item.file);
const enhancedEvent = {
...evt,
target: {
...evt.target,
files: Object.assign(remainingFiles, { item: (index) => remainingFiles[index] || null }),
deletedFile: deletedItem,
deletedFileName: deletedItem.name,
remainingFiles: remainingItems,
currentFiles: remainingItems,
action: "remove"
}
};
if (onDelete) onDelete(enhancedEvent);
if (onChange) onChange(enhancedEvent);
} else {
const deletedFileName = legacyFileNames[index];
setLegacyFileNames(legacyFileNames.filter((filename) => filename !== deletedFileName));
if (onDelete) onDelete(evt);
}
if (onClick) onClick(evt);
uploaderButton.current?.focus?.();
}
}, [
enhancedFileUploaderEnabled,
fileItems,
legacyFileNames,
onDelete,
onChange,
onClick,
uploaderButton
]);
useImperativeHandle(ref, () => ({
clearFiles() {
if (enhancedFileUploaderEnabled) {
const previousItems = [...fileItems];
setFileItems([]);
if (onChange && previousItems.length > 0) onChange({
target: {
files: Object.assign([], { item: () => null }),
clearedFiles: previousItems,
currentFiles: [],
action: "clear"
},
preventDefault: () => {},
stopPropagation: () => {}
});
} else setLegacyFileNames([]);
},
...enhancedFileUploaderEnabled && { getCurrentFiles() {
return [...fileItems];
} }
}), [
enhancedFileUploaderEnabled,
fileItems,
onChange
]);
const classes = classNames({
[`${prefix}--form-item`]: true,
[className]: className
});
const getHelperLabelClasses = (baseClass) => classNames(baseClass, { [`${prefix}--label-description--disabled`]: disabled });
const selectedFileClasses = classNames(`${prefix}--file__selected-file`, {
[`${prefix}--file__selected-file--md`]: size === "field" || size === "md",
[`${prefix}--file__selected-file--sm`]: size === "small" || size === "sm"
});
const displayFiles = enhancedFileUploaderEnabled ? fileItems.map((item, index) => ({
name: item.name,
key: item.uuid,
index
})) : legacyFileNames.map((name, index) => ({
name,
key: index,
index
}));
return /* @__PURE__ */ jsxs("div", {
className: classes,
...other,
children: [
!labelTitle ? null : /* @__PURE__ */ jsx(Text, {
as: "h3",
className: getHelperLabelClasses(`${prefix}--file--label`),
children: labelTitle
}),
/* @__PURE__ */ jsx(Text, {
as: "p",
className: getHelperLabelClasses(`${prefix}--label-description`),
id: fileUploaderInstanceId,
children: labelDescription
}),
/* @__PURE__ */ jsx(FileUploaderButton, {
innerRef: uploaderButton,
disabled,
labelText: buttonLabel,
multiple,
buttonKind,
onChange: handleChange,
disableLabelChanges: true,
accept,
name,
size,
"aria-describedby": fileUploaderInstanceId
}),
/* @__PURE__ */ jsx("div", {
className: `${prefix}--file-container`,
children: displayFiles.length === 0 ? null : displayFiles.map((file) => /* @__PURE__ */ jsxs("span", {
className: selectedFileClasses,
ref: (node) => {
nodes[file.index] = node;
},
...other,
children: [/* @__PURE__ */ jsx(Text, {
as: "p",
className: `${prefix}--file-filename`,
id: enhancedFileUploaderEnabled ? `${fileUploaderInstanceId}-file-${fileItems[file.index]?.uuid || file.index}` : `${fileUploaderInstanceId}-file-${file.index}`,
children: file.name
}), /* @__PURE__ */ jsx("span", {
className: `${prefix}--file__state-container`,
children: /* @__PURE__ */ jsx(Filename, {
name: file.name,
iconDescription,
status: filenameStatus,
onKeyDown: (evt) => {
if (matches(evt, [Enter, Space])) handleClick(evt, {
index: file.index,
filenameStatus
});
},
onClick: (evt) => handleClick(evt, {
index: file.index,
filenameStatus
})
})
})]
}, file.key))
})
]
});
});
FileUploader.displayName = "FileUploader";
FileUploader.propTypes = {
accept: PropTypes.arrayOf(PropTypes.string),
buttonKind: PropTypes.oneOf(ButtonKinds),
buttonLabel: PropTypes.string,
className: PropTypes.string,
disabled: PropTypes.bool,
filenameStatus: PropTypes.oneOf([
"edit",
"complete",
"uploading"
]).isRequired,
iconDescription: PropTypes.string,
labelDescription: PropTypes.string,
labelTitle: PropTypes.string,
maxFileSize: PropTypes.number,
multiple: PropTypes.bool,
name: PropTypes.string,
onAddFiles: PropTypes.func,
onChange: PropTypes.func,
onClick: PropTypes.func,
onDelete: PropTypes.func,
size: PropTypes.oneOf([
"sm",
"small",
"md",
"field",
"lg"
])
};
//#endregion
export { FileUploader as default };